diff --git a/.flake8 b/.flake8 deleted file mode 100644 index d4e8f681b..000000000 --- a/.flake8 +++ /dev/null @@ -1,8 +0,0 @@ -[flake8] -exclude = .git,.tox,__pycache__,kasa/tests/newfakes.py,kasa/tests/test_fixtures.py -max-line-length = 88 -per-file-ignores = - kasa/tests/*.py:D100,D101,D102,D103,D104,F401 - docs/source/conf.py:D100,D103 -ignore = D105, D107, E203, E501, W503 -max-complexity = 18 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..581a1cb4e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +*.sh text eol=lf +*.json text eol=lf +*.md text eol=lf +*.rst text eol=lf diff --git a/.gitchangelog.rc b/.gitchangelog.rc deleted file mode 100644 index 8d6e74ec7..000000000 --- a/.gitchangelog.rc +++ /dev/null @@ -1 +0,0 @@ -include_merge = False diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..51396acef --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [rytilahti] diff --git a/.github/actions/setup/action.yaml b/.github/actions/setup/action.yaml new file mode 100644 index 000000000..b075d4514 --- /dev/null +++ b/.github/actions/setup/action.yaml @@ -0,0 +1,50 @@ +--- +name: Setup Environment +description: Install uv, configure the system python, and the package dependencies +inputs: + uv-install-options: + default: "" + uv-version: + default: 0.9.16 + python-version: + required: true + cache-pre-commit: + default: false + cache-version: + default: "v0.1" +runs: + using: composite + steps: + - name: Setup uv + id: setup-uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Setup Python + id: setup-python + uses: actions/setup-python@v6 + with: + python-version: ${{ inputs.python-version }} + allow-prereleases: true + + - name: Install Project Dependencies + id: install-project-dependencies + shell: bash + run: | + uv sync ${{ inputs.uv-install-options }} + + - 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 + + - name: pre-commit Cache + id: pre-commit-cache + if: inputs.cache-pre-commit == 'true' + uses: actions/cache@v4 + 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') }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..6fea377e3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,89 @@ +--- +name: CI + +on: + push: + branches: + - master + - patch + pull_request: + branches: + - master + - patch + - 'feat/**' + - 'fix/**' + - 'janitor/**' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + UV_VERSION: 0.9.16 + +jobs: + lint: + name: Perform Lint Checks + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.13] + steps: + - name: Checkout Source Files + id: checkout + uses: actions/checkout@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 + + - 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: lint + runs-on: ${{ matrix.os }} + strategy: + matrix: + python-version: [3.11, 3.12, 3.13] + os: [ubuntu-latest, macos-latest, windows-latest] + extras: [false, true] + exclude: + - os: macos-latest + extras: true + - os: windows-latest + extras: true + steps: + - name: Checkout Source Files + id: checkout + uses: actions/checkout@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 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 Code Coverage to Codecov + id: upload-code-coverage-to-codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 000000000..b9e1479cf --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,50 @@ +--- +name: CodeQL Checks + +on: + push: + branches: + - master + - patch + pull_request: + 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 + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [python] + + steps: + - name: Checkout Source Files + id: checkout + uses: actions/checkout@v6 + + - name: Initialize CodeQL + id: init-codeql + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + + - name: Perform CodeQL Analysis + id: perform-codeql-analysis + uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 000000000..92a9b319d --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,40 @@ +--- +name: Publish Packages + +on: + release: + types: [published] + +env: + UV_VERSION: 0.9.16 + PYTHON_VERSION: 3.13 + +jobs: + build-n-publish: + name: Build Release Packages + runs-on: ubuntu-latest + permissions: + id-token: write + steps: + - name: Checkout Source Files + id: checkout + uses: actions/checkout@v6 + + - name: Setup uv + id: setup-uv + uses: astral-sh/setup-uv@v7 + + - name: Setup Python + id: setup-python + uses: actions/setup-python@v6 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Build Packages + id: build-packages + shell: bash + run: uv build + + - name: Publish Release on PyPI + id: publish-release-on-pypi + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 000000000..cafa2ce92 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,70 @@ +--- +name: Stale + +on: + schedule: + - cron: "0 0 * * *" + workflow_dispatch: + +jobs: + stale: + if: github.repository_owner == 'python-kasa' + runs-on: ubuntu-latest + steps: + - name: Stale Issues and PRs Policy + id: stale-issues-and-prs-policy + uses: actions/stale@v10 + with: + repo-token: ${{ github.token }} + days-before-stale: 90 + days-before-close: 7 + operations-per-run: 250 + remove-stale-when-updated: true + stale-issue-label: "stale" + exempt-issue-labels: "no-stale,help-wanted,needs-more-information,waiting-for-reporter" + stale-pr-label: "stale" + exempt-pr-labels: "no-stale" + stale-pr-message: > + There hasn't been any activity on this pull request recently. This + pull request has been automatically marked as stale because of that + and will be closed if no further activity occurs within 7 days. + + If you are the author of this PR, please leave a comment if you want + to keep it open. Also, please rebase your PR onto the latest dev + branch to ensure that it's up to date with the latest changes. + + Thank you for your contribution! + stale-issue-message: > + There hasn't been any activity on this issue recently. This issue has + been automatically marked as stale because of that. It will be closed + if no further activity occurs. + + Please make sure to update to the latest python-kasa version and + check if that solves the issue. + + Thank you for your contributions. + + - name: needs-more-information and waiting-for-reporter Stale Issues Policy + id: specific-stale-issues-policy + uses: actions/stale@v10 + with: + repo-token: ${{ github.token }} + only-labels: "needs-more-information,waiting-for-reporter" + days-before-stale: 21 + days-before-close: 7 + days-before-pr-stale: -1 + days-before-pr-close: -1 + operations-per-run: 250 + remove-stale-when-updated: true + stale-issue-label: "stale" + exempt-issue-labels: "no-stale,help-wanted" + stale-issue-message: > + There hasn't been any activity on this issue recently and it has + been waiting for the reporter to provide information or an update. + This issue has been automatically marked as stale because of that. + It will be closed if no further activity occurs. + + Please make sure to update to the latest python-kasa version and + check if that solves the issue. + + Thank you for your contributions. diff --git a/.github_changelog_generator b/.github_changelog_generator new file mode 100644 index 000000000..538e6ec14 --- /dev/null +++ b/.github_changelog_generator @@ -0,0 +1,11 @@ +output=CHANGELOG.md +base=HISTORY.md +user=python-kasa +project=python-kasa +since-tag=0.3.5 +release-branch=master +usernames-as-github-logins=true +breaking_labels=breaking change +add-sections={"new-device":{"prefix":"**Added support for devices:**","labels":["new device"]},"docs":{"prefix":"**Documentation updates:**","labels":["documentation"]},"maintenance":{"prefix":"**Project maintenance:**","labels":["maintenance"]}} +exclude-labels=duplicate,question,invalid,wontfix,release-prep,stale +issues-wo-labels=false diff --git a/.gitignore b/.gitignore index 9da1e2353..573a4c08f 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ venv .venv /build +docs/build diff --git a/.hound.yml b/.hound.yml deleted file mode 100644 index 974239e02..000000000 --- a/.hound.yml +++ /dev/null @@ -1,5 +0,0 @@ -python: - enabled: true - config_file: tox.ini -flake8: - enabled: true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7606de0b1..efaefc970 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,14 @@ repos: + +- repo: https://github.com/astral-sh/uv-pre-commit + # uv version. + rev: 0.5.30 + hooks: + # Update the uv lockfile + - id: uv-lock + - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.4.0 + rev: v5.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -8,32 +16,42 @@ repos: - id: check-yaml - id: debug-statements - id: check-ast + - id: pretty-format-json + args: + - "--autofix" + - "--indent=4" -- repo: https://github.com/asottile/pyupgrade - rev: v1.25.2 - hooks: - - id: pyupgrade - args: ['--py36-plus'] - -- repo: https://github.com/python/black - rev: stable - hooks: - - id: black - -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.3.0 +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.9.6 hooks: - - id: flake8 - additional_dependencies: [flake8-docstrings] + - id: ruff + args: [--fix, --exit-non-zero-on-fix] + - id: ruff-format -- repo: https://github.com/pre-commit/mirrors-isort - rev: v4.3.21 +- repo: https://github.com/PyCQA/doc8 + rev: 'v1.1.2' hooks: - - id: isort - additional_dependencies: [toml] + - id: doc8 + additional_dependencies: [tomli] -- repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.740 +- repo: local hooks: + # Run mypy in the virtual environment so it uses the installed dependencies + # for more accurate checking than using the pre-commit mypy mirror - id: mypy -# args: [--no-strict-optional, --ignore-missing-imports] + name: mypy + entry: uv run mypy + language: system + types_or: [python, pyi] + require_serial: true + exclude: | # exclude required because --all-files passes py and pyi + (?x)^( + kasa/modulemapping\.py| + )$ + - id: generate-supported + name: Generate supported devices + description: This hook generates the supported device sections of README.md and SUPPORTED.md + entry: uv run ./devtools/generate_supported.py + language: system # Required or pre-commit creates a new venv + types: [json] + pass_filenames: false # passing filenames causes the hook to run in batches against all-files diff --git a/.readthedocs.yml b/.readthedocs.yml index 0413384f0..17b68ff4b 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,8 +1,22 @@ +version: 2 + +formats: all + +sphinx: + configuration: docs/source/conf.py + + build: - image: latest + os: ubuntu-22.04 + tools: + python: "3" + jobs: + pre_build: + - python -m sphinx -b linkcheck docs/source/ $READTHEDOCS_OUTPUT/linkcheck python: - version: 3.7 - pip_install: true - extra_requirements: - - docs + install: + - method: pip + path: . + extra_requirements: + - docs diff --git a/CHANGELOG.md b/CHANGELOG.md index 74fe8175d..68ddd4fe9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,109 +1,1646 @@ # Changelog -## [0.4.0.dev1](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev1) (2020-07-28) +## [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.4.0.dev0...0.4.0.dev1) +[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:** -- KL430 support [\#67](https://github.com/python-kasa/python-kasa/issues/67) -- Improve retry logic for discovery, messaging \(was: Handle empty responses\) [\#38](https://github.com/python-kasa/python-kasa/issues/38) -- Add support for lightstrips \(KL430\) [\#74](https://github.com/python-kasa/python-kasa/pull/74) ([rytilahti](https://github.com/rytilahti)) +- 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_temperate_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) + +**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) + +**Fixed bugs:** + +- Fix update errors on hubs with unsupported children [\#1344](https://github.com/python-kasa/python-kasa/pull/1344) (@sdb9696) +- Fix smartcam missing device id [\#1343](https://github.com/python-kasa/python-kasa/pull/1343) (@sdb9696) + +## [0.8.0](https://github.com/python-kasa/python-kasa/tree/0.8.0) (2024-11-26) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.7...0.8.0) + +**Release highlights:** + +- **Initial support for devices using the Tapo camera protocol, i.e. Tapo cameras and the Tapo H200 hub.** +- New camera functionality such as exposing RTSP streaming urls and camera pan/tilt. +- New way of testing module support for individual features with `has_feature` and `get_feature`. +- Adding voltage and current monitoring to `smart` devices. +- Migration from pydantic to mashumaro for serialization. + +Special thanks to @ryenitcher and @Puxtril for their new contributions to the improvement of the project! Also thanks to everyone who has helped with testing, contributing fixtures, and reporting issues! + +**Breaking change notes:** + +- Removed support for python <3.11. If you haven't got a compatible version try [uv](https://docs.astral.sh/uv/). +- Renamed `device_config.to_dict()` to `device_config.to_dict_control_credentials()`. `to_dict()` is still available but takes no parameters. +- From the `iot.Cloud` module the `iot.CloudInfo` class attributes have been converted to snake case. + + +**Breaking changes:** + +- Migrate iot cloud module to mashumaro [\#1282](https://github.com/python-kasa/python-kasa/pull/1282) (@sdb9696) +- Replace custom deviceconfig serialization with mashumaru [\#1274](https://github.com/python-kasa/python-kasa/pull/1274) (@sdb9696) +- Remove support for python \<3.11 [\#1273](https://github.com/python-kasa/python-kasa/pull/1273) (@sdb9696) + +**Implemented enhancements:** + +- Update cli modify presets to support smart devices [\#1295](https://github.com/python-kasa/python-kasa/pull/1295) (@sdb9696) +- Use credentials\_hash for smartcamera rtsp url [\#1293](https://github.com/python-kasa/python-kasa/pull/1293) (@sdb9696) +- Add voltage and current monitoring to smart Devices [\#1281](https://github.com/python-kasa/python-kasa/pull/1281) (@ryenitcher) +- 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) +- Add SmartCamera Led Module [\#1249](https://github.com/python-kasa/python-kasa/pull/1249) (@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) +- 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) +- 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:** + +- Add HS200 \(US\) Smart Fixture [\#1303](https://github.com/python-kasa/python-kasa/pull/1303) (@ZeliardM) +- Add smartcamera devices to supported docs [\#1257](https://github.com/python-kasa/python-kasa/pull/1257) (@sdb9696) +- Add P110M\(AU\) fixture [\#1244](https://github.com/python-kasa/python-kasa/pull/1244) (@rytilahti) +- Add L630 fixture [\#1240](https://github.com/python-kasa/python-kasa/pull/1240) (@rytilahti) +- Add EP40M Fixture [\#1238](https://github.com/python-kasa/python-kasa/pull/1238) (@ryenitcher) +- Add KS220 Fixture [\#1237](https://github.com/python-kasa/python-kasa/pull/1237) (@ryenitcher) + +**Documentation updates:** + +- Use markdown footnotes in supported.md [\#1310](https://github.com/python-kasa/python-kasa/pull/1310) (@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:** + +- 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) +- Move iot fixtures into iot subfolder [\#1299](https://github.com/python-kasa/python-kasa/pull/1299) (@sdb9696) +- Annotate fan\_speed\_level of Fan interface [\#1298](https://github.com/python-kasa/python-kasa/pull/1298) (@sdb9696) +- Add PIR ADC Values to Test Fixtures [\#1296](https://github.com/python-kasa/python-kasa/pull/1296) (@ryenitcher) +- Exclude \_\_getattr\_\_ for deprecated attributes from type checkers [\#1294](https://github.com/python-kasa/python-kasa/pull/1294) (@sdb9696) +- Simplify omit http\_client in DeviceConfig serialization [\#1292](https://github.com/python-kasa/python-kasa/pull/1292) (@sdb9696) +- Add SMART Voltage Monitoring to Fixtures [\#1290](https://github.com/python-kasa/python-kasa/pull/1290) (@ryenitcher) +- Remove pydantic dependency [\#1289](https://github.com/python-kasa/python-kasa/pull/1289) (@sdb9696) +- Do not print out all the fixture names at the start of test runs [\#1287](https://github.com/python-kasa/python-kasa/pull/1287) (@sdb9696) +- dump\_devinfo: iot light strip commands [\#1286](https://github.com/python-kasa/python-kasa/pull/1286) (@sdb9696) +- Migrate TurnOnBehaviours to mashumaro [\#1285](https://github.com/python-kasa/python-kasa/pull/1285) (@sdb9696) +- dump\_devinfo: query smartlife.iot.common.cloud for fw updates [\#1284](https://github.com/python-kasa/python-kasa/pull/1284) (@rytilahti) +- Migrate RuleModule to mashumaro [\#1283](https://github.com/python-kasa/python-kasa/pull/1283) (@sdb9696) +- Update sphinx dependency to 6.2 to fix docs build [\#1280](https://github.com/python-kasa/python-kasa/pull/1280) (@sdb9696) +- Update DiscoveryResult to use mashu Annotated Alias [\#1279](https://github.com/python-kasa/python-kasa/pull/1279) (@sdb9696) +- Extend dump\_devinfo iot queries [\#1278](https://github.com/python-kasa/python-kasa/pull/1278) (@sdb9696) +- Migrate triggerlogs to mashumaru [\#1277](https://github.com/python-kasa/python-kasa/pull/1277) (@sdb9696) +- Migrate smart firmware module to mashumaro [\#1276](https://github.com/python-kasa/python-kasa/pull/1276) (@sdb9696) +- Migrate IotLightPreset to mashumaru [\#1275](https://github.com/python-kasa/python-kasa/pull/1275) (@sdb9696) +- Allow callable coroutines for feature setters [\#1272](https://github.com/python-kasa/python-kasa/pull/1272) (@sdb9696) +- Fix deprecated SSLContext\(\) usage [\#1271](https://github.com/python-kasa/python-kasa/pull/1271) (@sdb9696) +- Use \_get\_device\_info methods for smart and iot devs in devtools [\#1265](https://github.com/python-kasa/python-kasa/pull/1265) (@sdb9696) +- Remove experimental support [\#1256](https://github.com/python-kasa/python-kasa/pull/1256) (@sdb9696) +- Move protocol modules into protocols package [\#1254](https://github.com/python-kasa/python-kasa/pull/1254) (@sdb9696) +- 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) +- Move transports into their own package [\#1247](https://github.com/python-kasa/python-kasa/pull/1247) (@rytilahti) +- Move tests folder to top level of project [\#1242](https://github.com/python-kasa/python-kasa/pull/1242) (@sdb9696) +- Add Additional Firmware Test Fixures [\#1234](https://github.com/python-kasa/python-kasa/pull/1234) (@ryenitcher) +- 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:** -- I don't python... how do I make this executable? [\#88](https://github.com/python-kasa/python-kasa/issues/88) -- ImportError: cannot import name 'smartplug' [\#87](https://github.com/python-kasa/python-kasa/issues/87) -- Support for P100 Smart Plug [\#83](https://github.com/python-kasa/python-kasa/issues/83) -- not able to pip install the library [\#82](https://github.com/python-kasa/python-kasa/issues/82) -- Discover.discover\(\) add selecting network interface \[pull request\] [\#78](https://github.com/python-kasa/python-kasa/issues/78) -- LB100 unable to turn on or off the lights [\#68](https://github.com/python-kasa/python-kasa/issues/68) -- Improve poetry usage documentation [\#60](https://github.com/python-kasa/python-kasa/issues/60) -- sys\_info not None fails assertion [\#55](https://github.com/python-kasa/python-kasa/issues/55) -- Upload pre-release to pypi for easier testing [\#17](https://github.com/python-kasa/python-kasa/issues/17) +- Expose Fan speed range from the library [\#1008](https://github.com/python-kasa/python-kasa/issues/1008) +- \[META\] 0.7 series - module support for SMART devices, support for introspectable device features and refactoring the library [\#783](https://github.com/python-kasa/python-kasa/issues/783) + +## [0.7.7](https://github.com/python-kasa/python-kasa/tree/0.7.7) (2024-11-04) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.6...0.7.7) + +**Release summary:** + +- Bugfix for child device device creation error with credentials_hash +- PIR support for iot dimmers and wall switches. +- Various small enhancements and project improvements. + +**Implemented enhancements:** + +- Add PIR&LAS for wall switches mentioning PIR support [\#1227](https://github.com/python-kasa/python-kasa/pull/1227) (@rytilahti) +- Expose ambient light setting for iot dimmers [\#1210](https://github.com/python-kasa/python-kasa/pull/1210) (@rytilahti) +- Expose PIR enabled setting for iot dimmers [\#1174](https://github.com/python-kasa/python-kasa/pull/1174) (@rytilahti) +- Add childprotection module [\#1141](https://github.com/python-kasa/python-kasa/pull/1141) (@rytilahti) +- Initial trigger logs implementation [\#900](https://github.com/python-kasa/python-kasa/pull/900) (@rytilahti) + +**Fixed bugs:** + +- Fix AES child device creation error [\#1220](https://github.com/python-kasa/python-kasa/pull/1220) (@sdb9696) + +**Project maintenance:** + +- Update TC65 fixture [\#1225](https://github.com/python-kasa/python-kasa/pull/1225) (@rytilahti) +- Update smartcamera fixtures from latest dump\_devinfo [\#1224](https://github.com/python-kasa/python-kasa/pull/1224) (@sdb9696) +- Add component queries to smartcamera devices [\#1223](https://github.com/python-kasa/python-kasa/pull/1223) (@sdb9696) +- Update try\_connect\_all to be more efficient and report attempts [\#1222](https://github.com/python-kasa/python-kasa/pull/1222) (@sdb9696) +- Use stacklevel=2 for warnings to report on callsites [\#1219](https://github.com/python-kasa/python-kasa/pull/1219) (@rytilahti) +- parse\_pcap\_klap: various code cleanups [\#1138](https://github.com/python-kasa/python-kasa/pull/1138) (@rytilahti) + +## [0.7.6](https://github.com/python-kasa/python-kasa/tree/0.7.6) (2024-10-29) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.5...0.7.6) + +**Release summary:** + +- Experimental support for Tapo cameras and the Tapo H200 hub which uses the same protocol. +- Better timestamp support across all devices. +- Support for new devices P304M, S200D and S200B (see README.md for note on the S200 support). +- Various other fixes and minor features. + +**Implemented enhancements:** + +- Add support for setting the timezone [\#436](https://github.com/python-kasa/python-kasa/issues/436) +- Add stream\_rtsp\_url to camera module [\#1197](https://github.com/python-kasa/python-kasa/pull/1197) (@sdb9696) +- Try default logon credentials in SslAesTransport [\#1195](https://github.com/python-kasa/python-kasa/pull/1195) (@sdb9696) +- Allow enabling experimental devices from environment variable [\#1194](https://github.com/python-kasa/python-kasa/pull/1194) (@sdb9696) +- Add core device, child and camera modules to smartcamera [\#1193](https://github.com/python-kasa/python-kasa/pull/1193) (@sdb9696) +- Fallback to get\_current\_power if get\_energy\_usage does not provide current\_power [\#1186](https://github.com/python-kasa/python-kasa/pull/1186) (@Fulch36) +- Add https parameter to device class factory [\#1184](https://github.com/python-kasa/python-kasa/pull/1184) (@sdb9696) +- Add discovery list command to cli [\#1183](https://github.com/python-kasa/python-kasa/pull/1183) (@sdb9696) +- Add Time module to SmartCamera devices [\#1182](https://github.com/python-kasa/python-kasa/pull/1182) (@sdb9696) +- Add try\_connect\_all to allow initialisation without udp broadcast [\#1171](https://github.com/python-kasa/python-kasa/pull/1171) (@sdb9696) +- Update dump\_devinfo for smart camera protocol [\#1169](https://github.com/python-kasa/python-kasa/pull/1169) (@sdb9696) +- Enable newer encrypted discovery protocol [\#1168](https://github.com/python-kasa/python-kasa/pull/1168) (@sdb9696) +- Initial TapoCamera support [\#1165](https://github.com/python-kasa/python-kasa/pull/1165) (@sdb9696) +- Add waterleak alert timestamp [\#1162](https://github.com/python-kasa/python-kasa/pull/1162) (@rytilahti) +- Create common Time module and add time set cli command [\#1157](https://github.com/python-kasa/python-kasa/pull/1157) (@sdb9696) + +**Fixed bugs:** + +- Only send 20002 discovery request with key included [\#1207](https://github.com/python-kasa/python-kasa/pull/1207) (@sdb9696) +- Fix SslAesTransport default login and add tests [\#1202](https://github.com/python-kasa/python-kasa/pull/1202) (@sdb9696) +- Fix device\_config serialisation of https value [\#1196](https://github.com/python-kasa/python-kasa/pull/1196) (@sdb9696) + +**Added support for devices:** + +- Add S200B\(EU\) fw 1.11.0 fixture [\#1205](https://github.com/python-kasa/python-kasa/pull/1205) (@sdb9696) +- Add TC65 fixture [\#1200](https://github.com/python-kasa/python-kasa/pull/1200) (@rytilahti) +- Add P304M\(UK\) test fixture [\#1185](https://github.com/python-kasa/python-kasa/pull/1185) (@Fulch36) +- Add H200 experimental fixture [\#1180](https://github.com/python-kasa/python-kasa/pull/1180) (@sdb9696) +- Add S200D button fixtures [\#1161](https://github.com/python-kasa/python-kasa/pull/1161) (@rytilahti) + +**Project maintenance:** + +- Fix mypy errors in parse\_pcap\_klap [\#1214](https://github.com/python-kasa/python-kasa/pull/1214) (@sdb9696) +- Make HSV NamedTuple creation more efficient [\#1211](https://github.com/python-kasa/python-kasa/pull/1211) (@sdb9696) +- dump\_devinfo: query get\_current\_brt for iot dimmers [\#1209](https://github.com/python-kasa/python-kasa/pull/1209) (@rytilahti) +- Add trigger\_logs and double\_click to dump\_devinfo helper [\#1208](https://github.com/python-kasa/python-kasa/pull/1208) (@sdb9696) +- Fix smartcamera childdevice module [\#1206](https://github.com/python-kasa/python-kasa/pull/1206) (@sdb9696) +- Add H200\(EU\) fw 1.3.2 fixture [\#1204](https://github.com/python-kasa/python-kasa/pull/1204) (@sdb9696) +- Do not pass None as timeout to http requests [\#1203](https://github.com/python-kasa/python-kasa/pull/1203) (@sdb9696) +- Update SMART test framework to use fake child protocols [\#1199](https://github.com/python-kasa/python-kasa/pull/1199) (@sdb9696) +- Allow passing an aiohttp client session during discover try\_connect\_all [\#1198](https://github.com/python-kasa/python-kasa/pull/1198) (@sdb9696) +- Add test framework for smartcamera [\#1192](https://github.com/python-kasa/python-kasa/pull/1192) (@sdb9696) +- Rename experimental fixtures folder to smartcamera [\#1191](https://github.com/python-kasa/python-kasa/pull/1191) (@sdb9696) +- Combine smartcamera error codes into SmartErrorCode [\#1190](https://github.com/python-kasa/python-kasa/pull/1190) (@sdb9696) +- Allow deriving from SmartModule without being registered [\#1189](https://github.com/python-kasa/python-kasa/pull/1189) (@sdb9696) +- Improve supported module checks for hub children [\#1188](https://github.com/python-kasa/python-kasa/pull/1188) (@sdb9696) +- Update smartcamera to support single get/set/do requests [\#1187](https://github.com/python-kasa/python-kasa/pull/1187) (@sdb9696) +- Add S200B\(US\) fw 1.12.0 fixture [\#1181](https://github.com/python-kasa/python-kasa/pull/1181) (@sdb9696) +- Add T110\(US\), T310\(US\) and T315\(US\) sensor fixtures [\#1179](https://github.com/python-kasa/python-kasa/pull/1179) (@sdb9696) +- Enforce EOLs for \*.rst and \*.md [\#1178](https://github.com/python-kasa/python-kasa/pull/1178) (@rytilahti) +- Convert fixtures to use unix newlines [\#1177](https://github.com/python-kasa/python-kasa/pull/1177) (@rytilahti) +- Add motion sensor to known categories [\#1176](https://github.com/python-kasa/python-kasa/pull/1176) (@rytilahti) +- Drop urllib3 dependency and create ssl context in executor thread [\#1175](https://github.com/python-kasa/python-kasa/pull/1175) (@sdb9696) +- Expose smart child device map as a class constant [\#1173](https://github.com/python-kasa/python-kasa/pull/1173) (@sdb9696) + +## [0.7.5](https://github.com/python-kasa/python-kasa/tree/0.7.5) (2024-10-08) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.4...0.7.5) + +**Release summary:** + +- Fix for KP303 on Firmware 1.0.6 +- Fix for `on_since` value jitter +- Various maintenance items + +**Breaking changes:** + +- Make iot time timezone aware [\#1147](https://github.com/python-kasa/python-kasa/pull/1147) (@sdb9696) + +**Fixed bugs:** + +- Send empty dictionary instead of null for iot queries [\#1145](https://github.com/python-kasa/python-kasa/pull/1145) (@sdb9696) +- parse\_pcap\_klap: require source host [\#1137](https://github.com/python-kasa/python-kasa/pull/1137) (@rytilahti) +- parse\_pcap\_klap: use request\_uri for matching the response [\#1136](https://github.com/python-kasa/python-kasa/pull/1136) (@rytilahti) +- Use tzinfo in time constructor instead of astime for iot devices [\#1158](https://github.com/python-kasa/python-kasa/pull/1158) (@sdb9696) +- Stabilise on\_since value for smart devices [\#1144](https://github.com/python-kasa/python-kasa/pull/1144) (@sdb9696) + +**Project maintenance:** + +- Move feature initialization from \_\_init\_\_ to \_initialize\_features [\#1140](https://github.com/python-kasa/python-kasa/pull/1140) (@rytilahti) +- Cache zoneinfo for smart devices [\#1156](https://github.com/python-kasa/python-kasa/pull/1156) (@sdb9696) +- Correctly define SmartModule.call as an async function [\#1148](https://github.com/python-kasa/python-kasa/pull/1148) (@sdb9696) +- Remove async magic patch from tests [\#1146](https://github.com/python-kasa/python-kasa/pull/1146) (@sdb9696) + +**Closed issues:** + +- Move code examples out from docs [\#630](https://github.com/python-kasa/python-kasa/issues/630) + +## [0.7.4](https://github.com/python-kasa/python-kasa/tree/0.7.4) (2024-09-27) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.3...0.7.4) + +**Release summary:** + +- KL135 color temp range corrected to 9000k max +- Minor enhancements and project maintenance + +**Implemented enhancements:** + +- Add factory\_reset\(\) to iotdevice [\#1125](https://github.com/python-kasa/python-kasa/pull/1125) (@rytilahti) +- Add reboot\(\) to the device interface [\#1124](https://github.com/python-kasa/python-kasa/pull/1124) (@rytilahti) +- Add factory-reset command to cli [\#1108](https://github.com/python-kasa/python-kasa/pull/1108) (@rytilahti) + +**Fixed bugs:** + +- Extend KL135 ct range up to 9000K [\#1123](https://github.com/python-kasa/python-kasa/pull/1123) (@rytilahti) +- Fix cli command for device off [\#1121](https://github.com/python-kasa/python-kasa/pull/1121) (@sdb9696) + +**Project maintenance:** + +- Use pytest-socket to ensure no tests are performing io [\#1133](https://github.com/python-kasa/python-kasa/pull/1133) (@sdb9696) +- Enable ruff lint pycodestyle warnings [\#1132](https://github.com/python-kasa/python-kasa/pull/1132) (@sdb9696) +- Add autouse fixture to patch asyncio.sleep [\#1131](https://github.com/python-kasa/python-kasa/pull/1131) (@sdb9696) +- Mock asyncio.sleep for klapprotocol tests [\#1130](https://github.com/python-kasa/python-kasa/pull/1130) (@rytilahti) +- Add fixture for T110 fw 1.9.0 [\#1129](https://github.com/python-kasa/python-kasa/pull/1129) (@rytilahti) +- Speed up and simplify github workflows [\#1128](https://github.com/python-kasa/python-kasa/pull/1128) (@sdb9696) +- Add KS200M\(US\) fw 1.0.12 fixture [\#1127](https://github.com/python-kasa/python-kasa/pull/1127) (@GatorEG) +- Add stale PR/Issue github workflow [\#1126](https://github.com/python-kasa/python-kasa/pull/1126) (@sdb9696) +- Add fixture for KL135\(US\) fw 1.0.15 [\#1122](https://github.com/python-kasa/python-kasa/pull/1122) (@rytilahti) + +## [0.7.3](https://github.com/python-kasa/python-kasa/tree/0.7.3) (2024-09-10) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.2...0.7.3) + +**Release summary:** + +- Migrate from `poetry` to `uv` for package/project management. +- Various minor code improvements + +**Project maintenance:** + +- Do not regenerate aes key pair [\#1114](https://github.com/python-kasa/python-kasa/pull/1114) (@sdb9696) +- Fix tests due to yarl URL str output change [\#1112](https://github.com/python-kasa/python-kasa/pull/1112) (@sdb9696) +- Add missing type hints to alarm module [\#1111](https://github.com/python-kasa/python-kasa/pull/1111) (@rytilahti) +- Add KH100 EU fixtures [\#1109](https://github.com/python-kasa/python-kasa/pull/1109) (@rytilahti) +- Migrate from poetry to uv for dependency and package management [\#986](https://github.com/python-kasa/python-kasa/pull/986) (@sdb9696) + +## [0.7.2](https://github.com/python-kasa/python-kasa/tree/0.7.2) (2024-08-30) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.1...0.7.2) + +**Release summary:** + +- **Breaking** change to disable including the check for the latest firmware for tapo devices and newer kasa devices in the standard update cycle. To check for the latest firmware call `check_latest_firmware` on the firmware module or run the `check_latest_firmware` feature. +- Minor bugfixes and improvements. + +**Breaking changes:** + +- Disable automatic updating of latest firmware [\#1103](https://github.com/python-kasa/python-kasa/pull/1103) (@sdb9696) + +**Implemented enhancements:** + +- Improve performance of dict merge code [\#1097](https://github.com/python-kasa/python-kasa/pull/1097) (@bdraco) + +**Fixed bugs:** + +- Fix logging in iotdevice when a module is module not supported [\#1100](https://github.com/python-kasa/python-kasa/pull/1100) (@bdraco) + +**Documentation updates:** + +- Fix incorrect docs link in contributing.md [\#1099](https://github.com/python-kasa/python-kasa/pull/1099) (@sdb9696) + +**Project maintenance:** + +- Remove top level await xdoctest fixture [\#1098](https://github.com/python-kasa/python-kasa/pull/1098) (@sdb9696) +- Enable python 3.13, allow pre-releases for CI [\#1086](https://github.com/python-kasa/python-kasa/pull/1086) (@rytilahti) +- Add flake8-pytest-style \(PT\) for ruff [\#1105](https://github.com/python-kasa/python-kasa/pull/1105) (@rytilahti) +- Add flake8-logging \(LOG\) and flake8-logging-format \(G\) for ruff [\#1104](https://github.com/python-kasa/python-kasa/pull/1104) (@rytilahti) +- Add missing typing\_extensions dependency [\#1101](https://github.com/python-kasa/python-kasa/pull/1101) (@sdb9696) + +## [0.7.1](https://github.com/python-kasa/python-kasa/tree/0.7.1) (2024-07-31) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.5...0.7.1) + +**Release highlights:** +- This release consists mainly of bugfixes and project improvements. +- There is also new support for Tapo T100 motion sensors. +- The CLI now supports child devices on all applicable commands. + +**Implemented enhancements:** + +- Expose reboot action [\#1073](https://github.com/python-kasa/python-kasa/pull/1073) (@rytilahti) +- Decrypt KLAP data from PCAP files [\#1041](https://github.com/python-kasa/python-kasa/pull/1041) (@clstrickland) +- Support child devices in all applicable cli commands [\#1020](https://github.com/python-kasa/python-kasa/pull/1020) (@sdb9696) + +**Fixed bugs:** + +- Error connecting to L920-5 Smart LED Strip [\#1040](https://github.com/python-kasa/python-kasa/issues/1040) +- Handle module errors more robustly and add query params to light preset and transition [\#1036](https://github.com/python-kasa/python-kasa/pull/1036) (@sdb9696) +- Fix iot light effect brightness [\#1092](https://github.com/python-kasa/python-kasa/pull/1092) (@sdb9696) +- Enable setting brightness with color temp for smart devices [\#1091](https://github.com/python-kasa/python-kasa/pull/1091) (@sdb9696) +- Do not send light\_on value to iot bulb set\_state [\#1090](https://github.com/python-kasa/python-kasa/pull/1090) (@sdb9696) +- Allow erroring modules to recover [\#1080](https://github.com/python-kasa/python-kasa/pull/1080) (@sdb9696) +- Raise KasaException on decryption errors [\#1078](https://github.com/python-kasa/python-kasa/pull/1078) (@sdb9696) +- Update smart request parameter handling [\#1061](https://github.com/python-kasa/python-kasa/pull/1061) (@sdb9696) +- Fix light preset module when list contains lighting effects [\#1048](https://github.com/python-kasa/python-kasa/pull/1048) (@sdb9696) +- Fix credential hash to return None on empty credentials [\#1029](https://github.com/python-kasa/python-kasa/pull/1029) (@sdb9696) + +**Added support for devices:** + +- Add support for T100 motion sensor [\#1079](https://github.com/python-kasa/python-kasa/pull/1079) (@rytilahti) + +**Project maintenance:** + +- Bump project version to 0.7.0.5 [\#1087](https://github.com/python-kasa/python-kasa/pull/1087) (@sdb9696) +- Add KP400\(US\) v1.0.4 fixture [\#1051](https://github.com/python-kasa/python-kasa/pull/1051) (@gimpy88) +- Add new HS220 kasa aes fixture [\#1050](https://github.com/python-kasa/python-kasa/pull/1050) (@sdb9696) +- Add KS205\(US\) v1.1.0 fixture [\#1049](https://github.com/python-kasa/python-kasa/pull/1049) (@gimpy88) +- Fix generate\_supported pre commit to run in venv [\#1085](https://github.com/python-kasa/python-kasa/pull/1085) (@sdb9696) +- Fix intermittently failing decryption error test [\#1082](https://github.com/python-kasa/python-kasa/pull/1082) (@sdb9696) +- Fix mypy pre-commit hook on windows [\#1081](https://github.com/python-kasa/python-kasa/pull/1081) (@sdb9696) +- Update RELEASING.md for patch releases [\#1076](https://github.com/python-kasa/python-kasa/pull/1076) (@sdb9696) +- Use monotonic time for query timing [\#1070](https://github.com/python-kasa/python-kasa/pull/1070) (@sdb9696) +- Fix parse\_pcap\_klap on windows and support default credentials [\#1068](https://github.com/python-kasa/python-kasa/pull/1068) (@sdb9696) +- Add fixture file for KP405 fw 1.0.6 [\#1063](https://github.com/python-kasa/python-kasa/pull/1063) (@daleye) +- Bump project version to 0.7.0.3 [\#1053](https://github.com/python-kasa/python-kasa/pull/1053) (@sdb9696) +- Add KS200M\(US\) v1.0.11 fixture [\#1047](https://github.com/python-kasa/python-kasa/pull/1047) (@sdb9696) +- Add KS225\(US\) v1.1.0 fixture [\#1046](https://github.com/python-kasa/python-kasa/pull/1046) (@sdb9696) +- Split out main cli module into lazily loaded submodules [\#1039](https://github.com/python-kasa/python-kasa/pull/1039) (@sdb9696) +- Structure cli into a package [\#1038](https://github.com/python-kasa/python-kasa/pull/1038) (@sdb9696) +- Add KP400 v1.0.3 fixture [\#1037](https://github.com/python-kasa/python-kasa/pull/1037) (@gimpy88) +- Add L920\(EU\) v1.1.3 fixture [\#1031](https://github.com/python-kasa/python-kasa/pull/1031) (@rytilahti) +- Update changelog generator config [\#1030](https://github.com/python-kasa/python-kasa/pull/1030) (@sdb9696) + +## [0.7.0.5](https://github.com/python-kasa/python-kasa/tree/0.7.0.5) (2024-07-18) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.4...0.7.0.5) + +A critical bugfix for an issue with some L530 Series devices and a redactor for sensitive info from debug logs. + +**Fixed bugs:** + +- Only refresh smart LightEffect module daily [\#1064](https://github.com/python-kasa/python-kasa/pull/1064) (@sdb9696) + +**Project maintenance:** + +- Redact sensitive info from debug logs [\#1069](https://github.com/python-kasa/python-kasa/pull/1069) (@sdb9696) + +## [0.7.0.4](https://github.com/python-kasa/python-kasa/tree/0.7.0.4) (2024-07-11) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.3...0.7.0.4) + +Critical bugfixes for issues with P100s and thermostats + + +**Fixed bugs:** + +- Use first known thermostat state as main state \(pick \#1054\) [\#1057](https://github.com/python-kasa/python-kasa/pull/1057) (@sdb9696) +- Defer module updates for less volatile modules \(pick 1052\) [\#1056](https://github.com/python-kasa/python-kasa/pull/1056) (@sdb9696) +- Use first known thermostat state as main state [\#1054](https://github.com/python-kasa/python-kasa/pull/1054) (@rytilahti) +- Defer module updates for less volatile modules [\#1052](https://github.com/python-kasa/python-kasa/pull/1052) (@sdb9696) + +## [0.7.0.3](https://github.com/python-kasa/python-kasa/tree/0.7.0.3) (2024-07-04) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.2...0.7.0.3) + +Critical bugfix for issue #1033 with ks225 and S505D light preset module errors. +Partially fixes light preset module errors with L920 and L930. + +**Fixed bugs:** + +- Handle module errors more robustly and add query params to light preset and transition [\#1043](https://github.com/python-kasa/python-kasa/pull/1043) (@sdb9696) + +**Documentation updates:** + +- Misleading usage of asyncio.run\(\) in code examples [\#348](https://github.com/python-kasa/python-kasa/issues/348) + +**Project maintenance:** + +- Enable CI on the patch branch [\#1042](https://github.com/python-kasa/python-kasa/pull/1042) (@sdb9696) + +## [0.7.0.2](https://github.com/python-kasa/python-kasa/tree/0.7.0.2) (2024-07-01) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.1...0.7.0.2) + +This patch release fixes some minor issues found out during testing against all new homeassistant platforms. + +**Fixed bugs:** + +- Disable multi-request on unknown errors [\#1027](https://github.com/python-kasa/python-kasa/pull/1027) (@sdb9696) +- Disable multi requests on json decode error during multi-request [\#1025](https://github.com/python-kasa/python-kasa/pull/1025) (@sdb9696) +- Fix changing brightness when effect is active [\#1019](https://github.com/python-kasa/python-kasa/pull/1019) (@rytilahti) +- Update light transition module to work with child devices [\#1017](https://github.com/python-kasa/python-kasa/pull/1017) (@sdb9696) +- Handle unknown error codes gracefully [\#1016](https://github.com/python-kasa/python-kasa/pull/1016) (@rytilahti) + +**Project maintenance:** + +- Make parent attribute on device consistent across iot and smart [\#1023](https://github.com/python-kasa/python-kasa/pull/1023) (@sdb9696) +- Cache SmartErrorCode creation [\#1022](https://github.com/python-kasa/python-kasa/pull/1022) (@bdraco) + +## [0.7.0.1](https://github.com/python-kasa/python-kasa/tree/0.7.0.1) (2024-06-25) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0...0.7.0.1) + +This patch release fixes some minor issues found out during testing against all new homeassistant platforms. + +**Fixed bugs:** + +- Disable lighttransition module on child devices [\#1013](https://github.com/python-kasa/python-kasa/pull/1013) (@sdb9696) +- Fix post update hook for iot child devices [\#1011](https://github.com/python-kasa/python-kasa/pull/1011) (@sdb9696) +- Fix iot strip so the children do not have led and cloud modules [\#1010](https://github.com/python-kasa/python-kasa/pull/1010) (@sdb9696) +- Require explicit feature type [\#1006](https://github.com/python-kasa/python-kasa/pull/1006) (@rytilahti) **Merged pull requests:** -- add a small example script to show library usage [\#90](https://github.com/python-kasa/python-kasa/pull/90) ([rytilahti](https://github.com/rytilahti)) -- add .readthedocs.yml required for poetry builds [\#89](https://github.com/python-kasa/python-kasa/pull/89) ([rytilahti](https://github.com/rytilahti)) -- Improve installation instructions [\#86](https://github.com/python-kasa/python-kasa/pull/86) ([rytilahti](https://github.com/rytilahti)) -- cli: Fix incorrect use of asyncio.run for temperature command [\#85](https://github.com/python-kasa/python-kasa/pull/85) ([rytilahti](https://github.com/rytilahti)) -- Add --transition to bulb-specific cli commands, fix turn\_{on,off} signatures [\#81](https://github.com/python-kasa/python-kasa/pull/81) ([rytilahti](https://github.com/rytilahti)) -- Improve bulb API, force turn on for all light changes as offline changes are not supported [\#76](https://github.com/python-kasa/python-kasa/pull/76) ([rytilahti](https://github.com/rytilahti)) -- Simplify API documentation by using doctests [\#73](https://github.com/python-kasa/python-kasa/pull/73) ([rytilahti](https://github.com/rytilahti)) -- Bulbs: allow specifying transition for state changes [\#70](https://github.com/python-kasa/python-kasa/pull/70) ([rytilahti](https://github.com/rytilahti)) -- Add transition support for SmartDimmer [\#69](https://github.com/python-kasa/python-kasa/pull/69) ([connorproctor](https://github.com/connorproctor)) +- Remove frost\_protection feature [\#1009](https://github.com/python-kasa/python-kasa/pull/1009) (@rytilahti) + +## [0.7.0](https://github.com/python-kasa/python-kasa/tree/0.7.0) (2024-06-23) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.2.1...0.7.0) -## [0.4.0.dev0](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev0) (2020-05-27) +We have been working hard behind the scenes to make this major release possible. +This release brings a major refactoring of the library to serve the ever-growing list of supported devices and paves the way for the future, yet unsupported devices. +The library now exposes device features through generic module and feature interfaces, that allows easy extension for future improvements. -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.3.5...0.4.0.dev0) +With almost 180 merged pull requests, over 200 changed files and since the last release, this release includes lots of goodies for everyone: +* Support for multi-functional devices like the dimmable fan KS240. +* Initial support for hubs and hub-connected devices like thermostats and sensors. +* Both IOT (legacy kasa) and SMART (tapo and newer kasa) devices now expose features and share common API. +* Modules to allow controlling new devices and functions such as light presets, fan controls, thermostats, humidity sensors, firmware updates and alarms. +* The common APIs allow dynamic introspection of available device features, making it easy to create dynamic interfaces. +* Improved documentation. + +Hope you enjoy the release, feel free to leave a comment and feedback! + +If you have a device that works, but is not listed in our supported devices list, feel free to [contribute fixture files](https://python-kasa.readthedocs.io/en/latest/contribute.html#contributing-fixture-files) to help us to make the library even better! + +> git diff 0.6.2.1..HEAD|diffstat +> 214 files changed, 26960 insertions(+), 6310 deletions(-) + +For more information on the changes please checkout our [documentation on the API changes](https://python-kasa.readthedocs.io/en/latest/deprecated.html) + +**Breaking changes:** + +- Add common energy module and deprecate device emeter attributes [\#976](https://github.com/python-kasa/python-kasa/pull/976) (@sdb9696) +- Move SmartBulb into SmartDevice [\#874](https://github.com/python-kasa/python-kasa/pull/874) (@sdb9696) +- Change state\_information to return feature values [\#804](https://github.com/python-kasa/python-kasa/pull/804) (@rytilahti) +- Remove SmartPlug in favor of SmartDevice [\#781](https://github.com/python-kasa/python-kasa/pull/781) (@rytilahti) +- Add generic interface for accessing device features [\#741](https://github.com/python-kasa/python-kasa/pull/741) (@rytilahti) **Implemented enhancements:** -- Add commands to control the wifi settings [\#45](https://github.com/python-kasa/python-kasa/pull/45) ([rytilahti](https://github.com/rytilahti)) +- Radiator support \(KE100\) [\#422](https://github.com/python-kasa/python-kasa/issues/422) +- Cleanup cli output [\#1000](https://github.com/python-kasa/python-kasa/pull/1000) (@rytilahti) +- Update mode, time, rssi and report\_interval feature names/units [\#995](https://github.com/python-kasa/python-kasa/pull/995) (@sdb9696) +- Add timezone to on\_since attributes [\#978](https://github.com/python-kasa/python-kasa/pull/978) (@sdb9696) +- Add type hints to feature set\_value [\#974](https://github.com/python-kasa/python-kasa/pull/974) (@sdb9696) +- Handle unknown light effect names and only calculate effect list once [\#973](https://github.com/python-kasa/python-kasa/pull/973) (@sdb9696) +- Support smart child modules queries [\#967](https://github.com/python-kasa/python-kasa/pull/967) (@sdb9696) +- Do not expose child modules on parent devices [\#964](https://github.com/python-kasa/python-kasa/pull/964) (@sdb9696) +- Do not add parent only modules to strip sockets [\#963](https://github.com/python-kasa/python-kasa/pull/963) (@sdb9696) +- Make device initialisation easier by reducing required imports [\#936](https://github.com/python-kasa/python-kasa/pull/936) (@sdb9696) +- Fix set\_state for common light modules [\#929](https://github.com/python-kasa/python-kasa/pull/929) (@sdb9696) +- Add state feature for iot devices [\#924](https://github.com/python-kasa/python-kasa/pull/924) (@rytilahti) +- Add post update hook to module and use in smart LightEffect [\#921](https://github.com/python-kasa/python-kasa/pull/921) (@sdb9696) +- Add LightEffect module for smart light strips [\#918](https://github.com/python-kasa/python-kasa/pull/918) (@sdb9696) +- Improve categorization of features [\#904](https://github.com/python-kasa/python-kasa/pull/904) (@rytilahti) +- Make get\_module return typed module [\#892](https://github.com/python-kasa/python-kasa/pull/892) (@sdb9696) +- Add LightEffectModule for dynamic light effects on SMART bulbs [\#887](https://github.com/python-kasa/python-kasa/pull/887) (@sdb9696) +- Implement choice feature type [\#880](https://github.com/python-kasa/python-kasa/pull/880) (@rytilahti) +- Add Fan interface for SMART devices [\#873](https://github.com/python-kasa/python-kasa/pull/873) (@sdb9696) +- Improve temperature controls [\#872](https://github.com/python-kasa/python-kasa/pull/872) (@rytilahti) +- Add precision\_hint to feature [\#871](https://github.com/python-kasa/python-kasa/pull/871) (@rytilahti) +- Be more lax on unknown SMART devices [\#863](https://github.com/python-kasa/python-kasa/pull/863) (@rytilahti) +- Handle paging of partial responses of lists like child\_device\_info [\#862](https://github.com/python-kasa/python-kasa/pull/862) (@sdb9696) +- Better firmware module support for devices not connected to the internet [\#854](https://github.com/python-kasa/python-kasa/pull/854) (@sdb9696) +- Re-query missing responses after multi request errors [\#850](https://github.com/python-kasa/python-kasa/pull/850) (@sdb9696) +- Implement action feature [\#849](https://github.com/python-kasa/python-kasa/pull/849) (@rytilahti) +- Add temperature control module for smart [\#848](https://github.com/python-kasa/python-kasa/pull/848) (@rytilahti) +- Implement feature categories [\#846](https://github.com/python-kasa/python-kasa/pull/846) (@rytilahti) +- Expose IOT emeter info as features [\#844](https://github.com/python-kasa/python-kasa/pull/844) (@rytilahti) +- Add support for feature units [\#843](https://github.com/python-kasa/python-kasa/pull/843) (@rytilahti) +- Add ColorModule for smart devices [\#840](https://github.com/python-kasa/python-kasa/pull/840) (@sdb9696) +- Add colortemp feature for iot devices [\#827](https://github.com/python-kasa/python-kasa/pull/827) (@rytilahti) +- Add support for firmware module v1 [\#821](https://github.com/python-kasa/python-kasa/pull/821) (@sdb9696) +- Add colortemp module [\#814](https://github.com/python-kasa/python-kasa/pull/814) (@rytilahti) +- Add iot brightness feature [\#808](https://github.com/python-kasa/python-kasa/pull/808) (@sdb9696) +- Revise device initialization and subsequent updates [\#807](https://github.com/python-kasa/python-kasa/pull/807) (@rytilahti) +- Add brightness module [\#806](https://github.com/python-kasa/python-kasa/pull/806) (@rytilahti) +- Support multiple child requests [\#795](https://github.com/python-kasa/python-kasa/pull/795) (@sdb9696) +- Support for on\_off\_gradually v2+ [\#793](https://github.com/python-kasa/python-kasa/pull/793) (@rytilahti) +- Improve smartdevice update module [\#791](https://github.com/python-kasa/python-kasa/pull/791) (@rytilahti) +- Add --child option to feature command [\#789](https://github.com/python-kasa/python-kasa/pull/789) (@rytilahti) +- Add temperature\_unit feature to t315 [\#788](https://github.com/python-kasa/python-kasa/pull/788) (@rytilahti) +- Add feature for ambient light sensor [\#787](https://github.com/python-kasa/python-kasa/pull/787) (@shifty35) +- Improve autooff name and unit [\#997](https://github.com/python-kasa/python-kasa/pull/997) (@rytilahti) +- Add unit\_getter for feature [\#993](https://github.com/python-kasa/python-kasa/pull/993) (@rytilahti) +- Add time sync command [\#951](https://github.com/python-kasa/python-kasa/pull/951) (@rytilahti) +- Add light presets common module to devices. [\#907](https://github.com/python-kasa/python-kasa/pull/907) (@sdb9696) +- Create common interfaces for remaining device types [\#895](https://github.com/python-kasa/python-kasa/pull/895) (@sdb9696) +- Add initial support for H100 and T315 [\#776](https://github.com/python-kasa/python-kasa/pull/776) (@rytilahti) +- Generalize smartdevice child support [\#775](https://github.com/python-kasa/python-kasa/pull/775) (@rytilahti) +- Raise CLI errors in debug mode [\#771](https://github.com/python-kasa/python-kasa/pull/771) (@sdb9696) +- Add cloud module for smartdevice [\#767](https://github.com/python-kasa/python-kasa/pull/767) (@rytilahti) +- Add firmware module for smartdevice [\#766](https://github.com/python-kasa/python-kasa/pull/766) (@rytilahti) +- Add fan module [\#764](https://github.com/python-kasa/python-kasa/pull/764) (@rytilahti) +- Add smartdevice module for led controls [\#761](https://github.com/python-kasa/python-kasa/pull/761) (@rytilahti) +- Auto auto-off module for smartdevice [\#760](https://github.com/python-kasa/python-kasa/pull/760) (@rytilahti) +- Add smartdevice module for smooth transitions [\#759](https://github.com/python-kasa/python-kasa/pull/759) (@rytilahti) +- Initial implementation for modularized smartdevice [\#757](https://github.com/python-kasa/python-kasa/pull/757) (@rytilahti) +- Let caller handle SMART errors on multi-requests [\#754](https://github.com/python-kasa/python-kasa/pull/754) (@sdb9696) +- Add 'shell' command to cli [\#738](https://github.com/python-kasa/python-kasa/pull/738) (@rytilahti) **Fixed bugs:** -- HSV cli command not working [\#43](https://github.com/python-kasa/python-kasa/issues/43) +- TAPO P100 \(hw 1.0.0, sw 1.1.3\) EU plug with 0.6.2.1 Kasa results JSON\_DECODE\_FAIL\_ERROR [\#819](https://github.com/python-kasa/python-kasa/issues/819) +- Cannot add Tapo Plug P110 to Home Assistant 2024.2.3 - Error in debug mode [\#797](https://github.com/python-kasa/python-kasa/issues/797) +- KS240 gets discovered but will not authenticate [\#749](https://github.com/python-kasa/python-kasa/issues/749) +- Individual commands do not work on discovered devices [\#71](https://github.com/python-kasa/python-kasa/issues/71) +- SMART.TAPOHUB does not work with 0.7.0 dev2 [\#958](https://github.com/python-kasa/python-kasa/issues/958) +- Fix --help on subcommands [\#885](https://github.com/python-kasa/python-kasa/issues/885) +- "Unclosed client session" Trying to set brightness on Tapo Bulb [\#828](https://github.com/python-kasa/python-kasa/issues/828) +- Error when trying to discover new Tapo P110 plug [\#818](https://github.com/python-kasa/python-kasa/issues/818) +- Individual errors cause failing the whole query [\#616](https://github.com/python-kasa/python-kasa/issues/616) +- Fix smart led status to report rule status [\#1002](https://github.com/python-kasa/python-kasa/pull/1002) (@sdb9696) +- Demote device\_time back to debug [\#1001](https://github.com/python-kasa/python-kasa/pull/1001) (@rytilahti) +- Add supported check to light transition module [\#971](https://github.com/python-kasa/python-kasa/pull/971) (@sdb9696) +- Fix switching off light effects for iot lights strips [\#961](https://github.com/python-kasa/python-kasa/pull/961) (@sdb9696) +- Add state features to iot strip sockets [\#960](https://github.com/python-kasa/python-kasa/pull/960) (@sdb9696) +- Ensure http delay logic works during default login attempt [\#959](https://github.com/python-kasa/python-kasa/pull/959) (@sdb9696) +- Fix fan speed level when off and derive smart fan module from common fan interface [\#957](https://github.com/python-kasa/python-kasa/pull/957) (@sdb9696) +- Do not show a zero error code when cli exits from showing help [\#935](https://github.com/python-kasa/python-kasa/pull/935) (@rytilahti) +- Initialize autooff features only when data is available [\#933](https://github.com/python-kasa/python-kasa/pull/933) (@rytilahti) +- Fix potential infinite loop if incomplete lists returned [\#920](https://github.com/python-kasa/python-kasa/pull/920) (@sdb9696) +- Add 'battery\_percentage' only when it's available [\#906](https://github.com/python-kasa/python-kasa/pull/906) (@rytilahti) +- Add missing alarm volume 'normal' [\#899](https://github.com/python-kasa/python-kasa/pull/899) (@rytilahti) +- Use Path.save for saving the fixtures [\#894](https://github.com/python-kasa/python-kasa/pull/894) (@rytilahti) +- Fix wifi scan re-querying error [\#891](https://github.com/python-kasa/python-kasa/pull/891) (@sdb9696) +- Fix --help on subcommands [\#886](https://github.com/python-kasa/python-kasa/pull/886) (@rytilahti) +- Fix smartprotocol response list handler to handle null reponses [\#884](https://github.com/python-kasa/python-kasa/pull/884) (@sdb9696) +- Improve feature setter robustness [\#870](https://github.com/python-kasa/python-kasa/pull/870) (@rytilahti) +- smartbulb: Limit brightness range to 1-100 [\#829](https://github.com/python-kasa/python-kasa/pull/829) (@rytilahti) +- Fix energy module calling get\_current\_power [\#798](https://github.com/python-kasa/python-kasa/pull/798) (@sdb9696) +- Fix auto update switch [\#786](https://github.com/python-kasa/python-kasa/pull/786) (@rytilahti) +- Fix to call update when only --device-family passed to cli [\#987](https://github.com/python-kasa/python-kasa/pull/987) (@sdb9696) +- Disallow non-targeted device commands [\#982](https://github.com/python-kasa/python-kasa/pull/982) (@rytilahti) +- Require update in cli for wifi commands [\#956](https://github.com/python-kasa/python-kasa/pull/956) (@rytilahti) +- Do not raise on multi-request errors on child devices [\#949](https://github.com/python-kasa/python-kasa/pull/949) (@rytilahti) +- Fix P100 errors on multi-requests [\#930](https://github.com/python-kasa/python-kasa/pull/930) (@sdb9696) +- Retry query on 403 after successful handshake [\#785](https://github.com/python-kasa/python-kasa/pull/785) (@sdb9696) +- Ensure connections are closed when cli is finished [\#752](https://github.com/python-kasa/python-kasa/pull/752) (@sdb9696) +- Fix for P100 on fw 1.1.3 login\_version none [\#751](https://github.com/python-kasa/python-kasa/pull/751) (@sdb9696) +- Pass timeout parameters to discover\_single [\#744](https://github.com/python-kasa/python-kasa/pull/744) (@sdb9696) +- Reduce AuthenticationExceptions raising from transports [\#740](https://github.com/python-kasa/python-kasa/pull/740) (@sdb9696) +- Do not crash cli on missing discovery info [\#735](https://github.com/python-kasa/python-kasa/pull/735) (@rytilahti) +- Fix port-override for aes&klap transports [\#734](https://github.com/python-kasa/python-kasa/pull/734) (@rytilahti) +- Fix discovery cli to print devices not printed during discovery timeout [\#670](https://github.com/python-kasa/python-kasa/pull/670) (@sdb9696) + +**Added support for devices:** + +- Add fixture for L920-5\(EU\) 1.0.7 [\#972](https://github.com/python-kasa/python-kasa/pull/972) (@rytilahti) +- Add P115 fixture [\#950](https://github.com/python-kasa/python-kasa/pull/950) (@rytilahti) +- Add some device fixtures [\#948](https://github.com/python-kasa/python-kasa/pull/948) (@rytilahti) +- Add fixture for S505D [\#947](https://github.com/python-kasa/python-kasa/pull/947) (@rytilahti) +- Add fixture for p300 1.0.15 [\#915](https://github.com/python-kasa/python-kasa/pull/915) (@rytilahti) +- Add H100 1.5.10 and KE100 2.4.0 fixtures [\#905](https://github.com/python-kasa/python-kasa/pull/905) (@rytilahti) +- Add fixture for waterleak sensor T300 [\#897](https://github.com/python-kasa/python-kasa/pull/897) (@rytilahti) +- Add support for contact sensor \(T110\) [\#877](https://github.com/python-kasa/python-kasa/pull/877) (@rytilahti) +- Add support for waterleak sensor \(T300\) [\#876](https://github.com/python-kasa/python-kasa/pull/876) (@rytilahti) +- Add support for KH100 hub [\#847](https://github.com/python-kasa/python-kasa/pull/847) (@Adriandorr) +- Support for new ks240 fan/light wall switch [\#839](https://github.com/python-kasa/python-kasa/pull/839) (@sdb9696) +- Add P100 fw 1.4.0 fixture [\#820](https://github.com/python-kasa/python-kasa/pull/820) (@sdb9696) +- Add fixture for P110 sw 1.0.7 [\#801](https://github.com/python-kasa/python-kasa/pull/801) (@rytilahti) +- Add updated l530 fixture 1.1.6 [\#792](https://github.com/python-kasa/python-kasa/pull/792) (@rytilahti) +- Fix devtools for P100 and add fixture [\#753](https://github.com/python-kasa/python-kasa/pull/753) (@sdb9696) +- Add H100 fixtures [\#737](https://github.com/python-kasa/python-kasa/pull/737) (@rytilahti) + +**Documentation updates:** + +- Document device features [\#755](https://github.com/python-kasa/python-kasa/issues/755) +- Clean up the README [\#979](https://github.com/python-kasa/python-kasa/issues/979) +- Cleanup README to use the new cli format [\#999](https://github.com/python-kasa/python-kasa/pull/999) (@rytilahti) +- Add 0.7 api changes section to docs [\#996](https://github.com/python-kasa/python-kasa/pull/996) (@sdb9696) +- Update docs with more howto examples [\#968](https://github.com/python-kasa/python-kasa/pull/968) (@sdb9696) +- Update documentation structure and start migrating to markdown [\#934](https://github.com/python-kasa/python-kasa/pull/934) (@sdb9696) +- Add tutorial doctest module and enable top level await [\#919](https://github.com/python-kasa/python-kasa/pull/919) (@sdb9696) +- Add warning about tapo watchdog [\#902](https://github.com/python-kasa/python-kasa/pull/902) (@rytilahti) +- Move contribution instructions into docs [\#901](https://github.com/python-kasa/python-kasa/pull/901) (@rytilahti) +- Add rust tapo link to README [\#857](https://github.com/python-kasa/python-kasa/pull/857) (@rytilahti) +- Update README to be more approachable for new users [\#994](https://github.com/python-kasa/python-kasa/pull/994) (@rytilahti) +- Enable shell extra for installing ptpython and rich [\#782](https://github.com/python-kasa/python-kasa/pull/782) (@sdb9696) +- Add WallSwitch device type and autogenerate supported devices docs [\#758](https://github.com/python-kasa/python-kasa/pull/758) (@sdb9696) + +**Project maintenance:** + +- Remove anyio dependency from pyproject.toml [\#990](https://github.com/python-kasa/python-kasa/pull/990) (@sdb9696) +- Configure mypy to run in virtual environment and fix resulting issues [\#989](https://github.com/python-kasa/python-kasa/pull/989) (@sdb9696) +- Better checking of child modules not supported by parent device [\#966](https://github.com/python-kasa/python-kasa/pull/966) (@sdb9696) +- Use freezegun for testing aes http client delays [\#954](https://github.com/python-kasa/python-kasa/pull/954) (@sdb9696) +- Fix passing custom port for dump\_devinfo [\#938](https://github.com/python-kasa/python-kasa/pull/938) (@rytilahti) +- Update release playbook [\#932](https://github.com/python-kasa/python-kasa/pull/932) (@rytilahti) +- Deprecate device level light, effect and led attributes [\#916](https://github.com/python-kasa/python-kasa/pull/916) (@sdb9696) +- Update cli to use common modules and remove iot specific cli testing [\#913](https://github.com/python-kasa/python-kasa/pull/913) (@sdb9696) +- Deprecate is\_something attributes [\#912](https://github.com/python-kasa/python-kasa/pull/912) (@sdb9696) +- Rename bulb interface to light and move fan and light interface to interfaces [\#910](https://github.com/python-kasa/python-kasa/pull/910) (@sdb9696) +- Make module names consistent and remove redundant module casting [\#909](https://github.com/python-kasa/python-kasa/pull/909) (@sdb9696) +- Add child devices from hubs to generated list of supported devices [\#898](https://github.com/python-kasa/python-kasa/pull/898) (@sdb9696) +- Update interfaces so they all inherit from Device [\#893](https://github.com/python-kasa/python-kasa/pull/893) (@sdb9696) +- Update ks240 fixture with child device query info [\#890](https://github.com/python-kasa/python-kasa/pull/890) (@sdb9696) +- Use pydantic.v1 namespace on all pydantic versions [\#883](https://github.com/python-kasa/python-kasa/pull/883) (@rytilahti) +- Update dump\_devinfo to print original exception stack on errors. [\#882](https://github.com/python-kasa/python-kasa/pull/882) (@sdb9696) +- Put modules back on children for wall switches [\#881](https://github.com/python-kasa/python-kasa/pull/881) (@sdb9696) +- Fix pypy39 CI cache on macos [\#868](https://github.com/python-kasa/python-kasa/pull/868) (@sdb9696) +- Do not try coverage upload for pypy [\#867](https://github.com/python-kasa/python-kasa/pull/867) (@sdb9696) +- Add runner.arch to cache-key in CI [\#866](https://github.com/python-kasa/python-kasa/pull/866) (@sdb9696) +- Fix broken CI due to missing python version on macos-latest [\#864](https://github.com/python-kasa/python-kasa/pull/864) (@sdb9696) +- Fix incorrect state updates in FakeTestProtocols [\#861](https://github.com/python-kasa/python-kasa/pull/861) (@sdb9696) +- Embed FeatureType inside Feature [\#860](https://github.com/python-kasa/python-kasa/pull/860) (@rytilahti) +- Include component\_nego with child fixtures [\#858](https://github.com/python-kasa/python-kasa/pull/858) (@sdb9696) +- Use brightness module for smartbulb [\#853](https://github.com/python-kasa/python-kasa/pull/853) (@rytilahti) +- Ignore system environment variables for tests [\#851](https://github.com/python-kasa/python-kasa/pull/851) (@rytilahti) +- Remove mock fixtures [\#845](https://github.com/python-kasa/python-kasa/pull/845) (@rytilahti) +- Enable and convert to future annotations [\#838](https://github.com/python-kasa/python-kasa/pull/838) (@sdb9696) +- Update poetry locks and pre-commit hooks [\#837](https://github.com/python-kasa/python-kasa/pull/837) (@sdb9696) +- Cache pipx in CI and add custom setup action [\#835](https://github.com/python-kasa/python-kasa/pull/835) (@sdb9696) +- Fix non python 3.8 compliant test [\#832](https://github.com/python-kasa/python-kasa/pull/832) (@sdb9696) +- Fix CI issue with python version used by pipx to install poetry [\#831](https://github.com/python-kasa/python-kasa/pull/831) (@sdb9696) +- Refactor split smartdevice tests to test\_{iot,smart}device [\#822](https://github.com/python-kasa/python-kasa/pull/822) (@rytilahti) +- Add pre-commit caching and fix poetry extras cache [\#817](https://github.com/python-kasa/python-kasa/pull/817) (@sdb9696) +- Fix slow aestransport and cli tests [\#816](https://github.com/python-kasa/python-kasa/pull/816) (@sdb9696) +- Do not run coverage on pypy and cache poetry envs [\#812](https://github.com/python-kasa/python-kasa/pull/812) (@sdb9696) +- Update test framework for dynamic parametrization [\#810](https://github.com/python-kasa/python-kasa/pull/810) (@sdb9696) +- Put child fixtures in subfolder [\#809](https://github.com/python-kasa/python-kasa/pull/809) (@sdb9696) +- Simplify device \_\_repr\_\_ [\#805](https://github.com/python-kasa/python-kasa/pull/805) (@rytilahti) +- Add T315 fixture, tests for humidity&temperature modules [\#802](https://github.com/python-kasa/python-kasa/pull/802) (@rytilahti) +- Do not fail fast on pypy CI jobs [\#799](https://github.com/python-kasa/python-kasa/pull/799) (@sdb9696) +- Update dump\_devinfo to collect child device info [\#796](https://github.com/python-kasa/python-kasa/pull/796) (@sdb9696) +- Refactor test framework [\#794](https://github.com/python-kasa/python-kasa/pull/794) (@sdb9696) +- Refactor devices into subpackages and deprecate old names [\#716](https://github.com/python-kasa/python-kasa/pull/716) (@sdb9696) +- Drop python3.8 support [\#992](https://github.com/python-kasa/python-kasa/pull/992) (@rytilahti) +- Make Light and Fan a common module interface [\#911](https://github.com/python-kasa/python-kasa/pull/911) (@sdb9696) +- Add missing firmware module import [\#774](https://github.com/python-kasa/python-kasa/pull/774) (@rytilahti) +- Fix dump\_devinfo scrubbing for ks240 [\#765](https://github.com/python-kasa/python-kasa/pull/765) (@rytilahti) +- Rename and deprecate exception classes [\#739](https://github.com/python-kasa/python-kasa/pull/739) (@sdb9696) **Closed issues:** -- Pull request \#54 broke installer? [\#66](https://github.com/python-kasa/python-kasa/issues/66) -- RFC: remove implicit updates after state changes? [\#61](https://github.com/python-kasa/python-kasa/issues/61) -- How to install? [\#57](https://github.com/python-kasa/python-kasa/issues/57) -- Request all necessary information during update\(\) [\#53](https://github.com/python-kasa/python-kasa/issues/53) -- HS107 Support [\#37](https://github.com/python-kasa/python-kasa/issues/37) -- Separate dimmer-related code from smartplug class [\#33](https://github.com/python-kasa/python-kasa/issues/33) -- Add Mac OSX and Windows for CI [\#30](https://github.com/python-kasa/python-kasa/issues/30) -- KP303\(UK\) does not pass check with pytest [\#27](https://github.com/python-kasa/python-kasa/issues/27) -- Remove sync interface wrapper [\#12](https://github.com/python-kasa/python-kasa/issues/12) -- Mass close pyhs100 issues and PRs [\#11](https://github.com/python-kasa/python-kasa/issues/11) -- Update readme [\#10](https://github.com/python-kasa/python-kasa/issues/10) -- Add contribution guidelines and instructions [\#8](https://github.com/python-kasa/python-kasa/issues/8) -- Convert discovery to use asyncio [\#7](https://github.com/python-kasa/python-kasa/issues/7) -- Python Version? [\#4](https://github.com/python-kasa/python-kasa/issues/4) -- Fix failing tests: KeyError: 'relay\_state' [\#2](https://github.com/python-kasa/python-kasa/issues/2) +- Improve timezone support [\#980](https://github.com/python-kasa/python-kasa/issues/980) + +## [0.6.2.1](https://github.com/python-kasa/python-kasa/tree/0.6.2.1) (2024-02-02) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.2...0.6.2.1) + +**Fixed bugs:** + +- Avoid crashing on childdevice property accesses [\#732](https://github.com/python-kasa/python-kasa/pull/732) (@rytilahti) + +**Added support for devices:** + +- Add TP15 fixture [\#730](https://github.com/python-kasa/python-kasa/pull/730) (@bdraco) +- Add TP25 fixtures [\#729](https://github.com/python-kasa/python-kasa/pull/729) (@bdraco) + +**Project maintenance:** + +- Various test code cleanups [\#725](https://github.com/python-kasa/python-kasa/pull/725) (@rytilahti) +- Unignore F401 for tests [\#724](https://github.com/python-kasa/python-kasa/pull/724) (@rytilahti) + +**Merged pull requests:** + +- Retain last two chars for children device\_id [\#733](https://github.com/python-kasa/python-kasa/pull/733) (@rytilahti) + +## [0.6.2](https://github.com/python-kasa/python-kasa/tree/0.6.2) (2024-01-29) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.1...0.6.2) + +Release highlights: +* Support for tapo power strips (P300) +* Performance improvements and bug fixes + +**Implemented enhancements:** + +- Implement alias set for tapodevice [\#721](https://github.com/python-kasa/python-kasa/pull/721) (@rytilahti) +- Reduce the number of times creating the cipher in klap [\#712](https://github.com/python-kasa/python-kasa/pull/712) (@bdraco) +- Use hashlib for klap [\#711](https://github.com/python-kasa/python-kasa/pull/711) (@bdraco) +- Initial support for tapos with child devices [\#720](https://github.com/python-kasa/python-kasa/pull/720) (@rytilahti) +- Avoid rebuilding urls for every request [\#715](https://github.com/python-kasa/python-kasa/pull/715) (@bdraco) +- Enable batching of multiple requests [\#662](https://github.com/python-kasa/python-kasa/pull/662) (@sdb9696) +- Sleep between discovery packets [\#656](https://github.com/python-kasa/python-kasa/pull/656) (@sdb9696) + +**Fixed bugs:** + +- Fix TapoBulb state information for non-dimmable SMARTSWITCH [\#726](https://github.com/python-kasa/python-kasa/pull/726) (@sdb9696) + +**Added support for devices:** + +- Update L510E\(US\) fixture with mac prefix [\#722](https://github.com/python-kasa/python-kasa/pull/722) (@sdb9696) +- Add P300 fixture [\#717](https://github.com/python-kasa/python-kasa/pull/717) (@rytilahti) + +**Documentation updates:** + +- Add protocol and transport documentation [\#663](https://github.com/python-kasa/python-kasa/pull/663) (@sdb9696) + +**Project maintenance:** + +- Use hashlib in place of hashes.Hash [\#714](https://github.com/python-kasa/python-kasa/pull/714) (@bdraco) +- Switch from TPLinkSmartHomeProtocol to IotProtocol/XorTransport [\#710](https://github.com/python-kasa/python-kasa/pull/710) (@sdb9696) + +**Merged pull requests:** + +- Add concrete XorTransport class with full implementation [\#646](https://github.com/python-kasa/python-kasa/pull/646) (@sdb9696) + +## [0.6.1](https://github.com/python-kasa/python-kasa/tree/0.6.1) (2024-01-25) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.0.1...0.6.1) + +Release highlights: +* Support for tapo wall switches +* Support for unprovisioned devices +* Performance and stability improvements + +**Implemented enhancements:** + +- Add support for tapo wall switches \(S500D\) [\#704](https://github.com/python-kasa/python-kasa/pull/704) (@bdraco) +- Add L930-5 fixture [\#694](https://github.com/python-kasa/python-kasa/pull/694) (@bdraco) +- Add fixtures for L510E [\#693](https://github.com/python-kasa/python-kasa/pull/693) (@bdraco) +- Add new cli command 'command' to execute arbitrary commands [\#692](https://github.com/python-kasa/python-kasa/pull/692) (@rytilahti) +- Allow raw-command and wifi without update [\#688](https://github.com/python-kasa/python-kasa/pull/688) (@rytilahti) +- Generate AES KeyPair lazily [\#687](https://github.com/python-kasa/python-kasa/pull/687) (@sdb9696) +- Add reboot and factory\_reset to tapodevice [\#686](https://github.com/python-kasa/python-kasa/pull/686) (@rytilahti) +- Try default tapo credentials for klap and aes [\#685](https://github.com/python-kasa/python-kasa/pull/685) (@sdb9696) + +**Fixed bugs:** + +- Do not crash on missing geolocation [\#701](https://github.com/python-kasa/python-kasa/pull/701) (@rytilahti) +- Fix P100 error getting conn closed when trying default login after login failure [\#690](https://github.com/python-kasa/python-kasa/pull/690) (@sdb9696) + +**Documentation updates:** + +- Document authenticated provisioning [\#634](https://github.com/python-kasa/python-kasa/pull/634) (@rytilahti) + +**Project maintenance:** + +- Add additional L900-10 fixture [\#707](https://github.com/python-kasa/python-kasa/pull/707) (@bdraco) +- Replace rich formatting stripper [\#706](https://github.com/python-kasa/python-kasa/pull/706) (@bdraco) + +**Merged pull requests:** + +- Fix overly greedy \_strip\_rich\_formatting [\#703](https://github.com/python-kasa/python-kasa/pull/703) (@bdraco) +- Update readme fixture checker and readme [\#699](https://github.com/python-kasa/python-kasa/pull/699) (@rytilahti) +- Update transport close/reset behaviour [\#689](https://github.com/python-kasa/python-kasa/pull/689) (@sdb9696) +- Check README for supported models [\#684](https://github.com/python-kasa/python-kasa/pull/684) (@rytilahti) +- Add P100 test fixture [\#683](https://github.com/python-kasa/python-kasa/pull/683) (@bdraco) +- Make dump\_devinfo request batch size configurable [\#681](https://github.com/python-kasa/python-kasa/pull/681) (@sdb9696) +- Add updated L920 fixture [\#680](https://github.com/python-kasa/python-kasa/pull/680) (@bdraco) +- Update fixtures from test devices [\#679](https://github.com/python-kasa/python-kasa/pull/679) (@bdraco) +- Show discovery data for state with verbose [\#678](https://github.com/python-kasa/python-kasa/pull/678) (@rytilahti) +- Add L530E\(US\) fixture [\#674](https://github.com/python-kasa/python-kasa/pull/674) (@bdraco) +- Add P135 fixture [\#673](https://github.com/python-kasa/python-kasa/pull/673) (@bdraco) +- Rename base TPLinkProtocol to BaseProtocol [\#669](https://github.com/python-kasa/python-kasa/pull/669) (@sdb9696) +- Add support for the S500 [\#705](https://github.com/python-kasa/python-kasa/pull/705) (@bdraco) +- Ensure login token is only sent if aes state is ESTABLISHED [\#702](https://github.com/python-kasa/python-kasa/pull/702) (@bdraco) +- Fix test\_klapprotocol test duration [\#698](https://github.com/python-kasa/python-kasa/pull/698) (@sdb9696) +- Renew the handshake session 20 minutes before we think it will expire [\#697](https://github.com/python-kasa/python-kasa/pull/697) (@bdraco) +- Add --batch-size hint to timeout errors in dump\_devinfo [\#696](https://github.com/python-kasa/python-kasa/pull/696) (@sdb9696) +- Refactor aestransport to use a state enum [\#691](https://github.com/python-kasa/python-kasa/pull/691) (@bdraco) +- Add 1003 \(TRANSPORT\_UNKNOWN\_CREDENTIALS\_ERROR\) [\#667](https://github.com/python-kasa/python-kasa/pull/667) (@rytilahti) + +## [0.6.0.1](https://github.com/python-kasa/python-kasa/tree/0.6.0.1) (2024-01-21) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.0...0.6.0.1) + +A patch release to improve the protocol handling. + +**Fixed bugs:** + +- Fix httpclient exceptions on read and improve error info [\#655](https://github.com/python-kasa/python-kasa/pull/655) (@sdb9696) +- Improve and document close behavior [\#654](https://github.com/python-kasa/python-kasa/pull/654) (@bdraco) + +**Merged pull requests:** + +- Add l900-5 1.1.0 fixture [\#664](https://github.com/python-kasa/python-kasa/pull/664) (@rytilahti) +- Add fixtures with new MAC mask [\#661](https://github.com/python-kasa/python-kasa/pull/661) (@sdb9696) +- Make close behaviour consistent across new protocols and transports [\#660](https://github.com/python-kasa/python-kasa/pull/660) (@sdb9696) +- Fix minor typos in docstrings [\#659](https://github.com/python-kasa/python-kasa/pull/659) (@bdraco) +- dump\_devinfo improvements [\#657](https://github.com/python-kasa/python-kasa/pull/657) (@rytilahti) + +## [0.6.0](https://github.com/python-kasa/python-kasa/tree/0.6.0) (2024-01-19) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.4...0.6.0) + +This major brings major changes to the library by adding support for devices that require authentication for communications, all of this being possible thanks to the great work by @sdb9696! + +This release adds support to a large range of previously unsupported devices, including: + +* Newer kasa-branded devices, including Matter-enabled devices like KP125M +* Newer hardware/firmware versions on some models, like EP25, that suddenly changed the used protocol +* Tapo-branded devices like plugs (P110), light bulbs (KL530), LED strips (L900, L920), and wall switches (KS205, KS225) +* UK variant of HS110, which was the first device using the new protocol + +If your device that is not currently listed as supported is working, please consider contributing a test fixture file. + +Special thanks goes to @SimonWilkinson who created the initial PR for the new communication protocol! + +**Breaking changes:** + +- Add DeviceConfig to allow specifying configuration parameters [\#569](https://github.com/python-kasa/python-kasa/pull/569) (@sdb9696) +- Move connect\_single to SmartDevice.connect [\#538](https://github.com/python-kasa/python-kasa/pull/538) (@bdraco) + +**Implemented enhancements:** + +- Support for KS225\(US\) Light Dimmer and KS205\(US\) Light Switch [\#589](https://github.com/python-kasa/python-kasa/issues/589) +- Set timeout using command line parameters [\#310](https://github.com/python-kasa/python-kasa/issues/310) +- Implement the new protocol \(HTTP over 80/tcp, 20002/udp for discovery\) [\#115](https://github.com/python-kasa/python-kasa/issues/115) +- Get child emeters with CLI [\#623](https://github.com/python-kasa/python-kasa/pull/623) (@Obbay2) +- Avoid linear search for emeter realtime and emeter\_today [\#622](https://github.com/python-kasa/python-kasa/pull/622) (@bdraco) +- Add update-credentials command [\#620](https://github.com/python-kasa/python-kasa/pull/620) (@rytilahti) +- Enable multiple requests in smartprotocol [\#584](https://github.com/python-kasa/python-kasa/pull/584) (@sdb9696) +- Improve CLI Discovery output [\#583](https://github.com/python-kasa/python-kasa/pull/583) (@sdb9696) +- Improve smartprotocol error handling and retries [\#578](https://github.com/python-kasa/python-kasa/pull/578) (@sdb9696) +- Request component\_nego only once for tapodevice [\#576](https://github.com/python-kasa/python-kasa/pull/576) (@rytilahti) +- Use consistent naming for cli envvars [\#570](https://github.com/python-kasa/python-kasa/pull/570) (@rytilahti) +- Add KP125M fixture and allow passing credentials for tests [\#567](https://github.com/python-kasa/python-kasa/pull/567) (@sbytnar) +- Make timeout configurable for cli [\#564](https://github.com/python-kasa/python-kasa/pull/564) (@rytilahti) +- Update dump\_devinfo to produce new TAPO/SMART fixtures [\#561](https://github.com/python-kasa/python-kasa/pull/561) (@sdb9696) +- Kasa KP125M basic emeter support [\#560](https://github.com/python-kasa/python-kasa/pull/560) (@sbytnar) +- Add klap support for TAPO protocol by splitting out Transports and Protocols [\#557](https://github.com/python-kasa/python-kasa/pull/557) (@sdb9696) +- Update dump\_devinfo to include 20002 discovery results [\#556](https://github.com/python-kasa/python-kasa/pull/556) (@sdb9696) +- Set TCP\_NODELAY to avoid needless buffering [\#554](https://github.com/python-kasa/python-kasa/pull/554) (@bdraco) +- Add support for the protocol used by TAPO devices and some newer KASA devices. [\#552](https://github.com/python-kasa/python-kasa/pull/552) (@sdb9696) +- Re-add protocol\_class parameter to connect [\#551](https://github.com/python-kasa/python-kasa/pull/551) (@sdb9696) +- Update discover single to handle hostnames [\#539](https://github.com/python-kasa/python-kasa/pull/539) (@sdb9696) +- Allow serializing and passing of credentials\_hashes in DeviceConfig [\#607](https://github.com/python-kasa/python-kasa/pull/607) (@sdb9696) +- Implement wifi interface for tapodevice [\#606](https://github.com/python-kasa/python-kasa/pull/606) (@rytilahti) +- Add support for KS205 and KS225 wall switches [\#594](https://github.com/python-kasa/python-kasa/pull/594) (@gimpy88) +- Add support for tapo bulbs [\#558](https://github.com/python-kasa/python-kasa/pull/558) (@rytilahti) +- Add klap protocol [\#509](https://github.com/python-kasa/python-kasa/pull/509) (@sdb9696) + +**Fixed bugs:** + +- dump\_devinfo crashes when credentials are not given [\#591](https://github.com/python-kasa/python-kasa/issues/591) +- Fix connection indeterminate state on cancellation [\#636](https://github.com/python-kasa/python-kasa/pull/636) (@bdraco) +- Check the ct range for color temp support [\#619](https://github.com/python-kasa/python-kasa/pull/619) (@rytilahti) +- Fix cli discover bug with None username/password [\#615](https://github.com/python-kasa/python-kasa/pull/615) (@sdb9696) +- Fix hsv setting for tapobulb [\#573](https://github.com/python-kasa/python-kasa/pull/573) (@rytilahti) +- Fix transport retries after close [\#568](https://github.com/python-kasa/python-kasa/pull/568) (@sdb9696) + +**Documentation updates:** + +- Update the documentation for 0.6 release [\#600](https://github.com/python-kasa/python-kasa/issues/600) +- Update docs for newer devices and DeviceConfig [\#614](https://github.com/python-kasa/python-kasa/pull/614) (@sdb9696) +- Update readme with clearer instructions, tapo support [\#571](https://github.com/python-kasa/python-kasa/pull/571) (@rytilahti) +- Add some more external links to README [\#541](https://github.com/python-kasa/python-kasa/pull/541) (@rytilahti) + +**Merged pull requests:** + +- Remove time logging in debug message [\#645](https://github.com/python-kasa/python-kasa/pull/645) (@sdb9696) +- Migrate http client to use aiohttp instead of httpx [\#643](https://github.com/python-kasa/python-kasa/pull/643) (@sdb9696) +- Encapsulate http client dependency [\#642](https://github.com/python-kasa/python-kasa/pull/642) (@sdb9696) +- Fix broken docs due to applehelp dependency [\#641](https://github.com/python-kasa/python-kasa/pull/641) (@sdb9696) +- Raise SmartDeviceException on invalid config dicts [\#640](https://github.com/python-kasa/python-kasa/pull/640) (@sdb9696) +- Add fixture for L920 [\#638](https://github.com/python-kasa/python-kasa/pull/638) (@bdraco) +- Raise TimeoutException on discover\_single timeout [\#632](https://github.com/python-kasa/python-kasa/pull/632) (@sdb9696) +- Add L900-10 fixture and it's additional component requests [\#629](https://github.com/python-kasa/python-kasa/pull/629) (@sdb9696) +- Avoid recreating struct each request in legacy protocol [\#628](https://github.com/python-kasa/python-kasa/pull/628) (@bdraco) +- Return alias as None for new discovery devices before update [\#627](https://github.com/python-kasa/python-kasa/pull/627) (@sdb9696) +- Update config to\_dict to exclude credentials if the hash is empty string [\#626](https://github.com/python-kasa/python-kasa/pull/626) (@sdb9696) +- Improve test coverage [\#625](https://github.com/python-kasa/python-kasa/pull/625) (@sdb9696) +- Add P125M and update EP25 fixtures [\#621](https://github.com/python-kasa/python-kasa/pull/621) (@bdraco) +- Use consistent envvars for dump\_devinfo credentials [\#618](https://github.com/python-kasa/python-kasa/pull/618) (@rytilahti) +- Mark L900-5 as supported [\#617](https://github.com/python-kasa/python-kasa/pull/617) (@rytilahti) +- Ship CHANGELOG only in sdist [\#610](https://github.com/python-kasa/python-kasa/pull/610) (@rytilahti) +- Cleanup credentials handling [\#605](https://github.com/python-kasa/python-kasa/pull/605) (@rytilahti) +- Update P110\(EU\) fixture [\#604](https://github.com/python-kasa/python-kasa/pull/604) (@rytilahti) +- Update L530 aes fixture [\#603](https://github.com/python-kasa/python-kasa/pull/603) (@rytilahti) +- Cleanup custom exception kwarg handling [\#602](https://github.com/python-kasa/python-kasa/pull/602) (@rytilahti) +- Pull up emeter handling to tapodevice base class [\#601](https://github.com/python-kasa/python-kasa/pull/601) (@rytilahti) +- Add L530\(EU\) klap fixture [\#598](https://github.com/python-kasa/python-kasa/pull/598) (@sdb9696) +- Update P110\(UK\) fixture [\#596](https://github.com/python-kasa/python-kasa/pull/596) (@sdb9696) +- Fix dump\_devinfo for unauthenticated [\#593](https://github.com/python-kasa/python-kasa/pull/593) (@sdb9696) +- Elevate --verbose to top-level option [\#590](https://github.com/python-kasa/python-kasa/pull/590) (@rytilahti) +- Add optional error code to exceptions [\#585](https://github.com/python-kasa/python-kasa/pull/585) (@sdb9696) +- Fix typo in cli.rst [\#581](https://github.com/python-kasa/python-kasa/pull/581) (@alanblake) +- Do login entirely within AesTransport [\#580](https://github.com/python-kasa/python-kasa/pull/580) (@sdb9696) +- Log smartprotocol requests [\#575](https://github.com/python-kasa/python-kasa/pull/575) (@rytilahti) +- Add new methods to dump\_devinfo and mask aliases [\#574](https://github.com/python-kasa/python-kasa/pull/574) (@sdb9696) +- Add EP25 smart fixture and improve test framework for SMART devices [\#572](https://github.com/python-kasa/python-kasa/pull/572) (@sdb9696) +- Re-add regional suffix to TAPO/SMART fixtures [\#566](https://github.com/python-kasa/python-kasa/pull/566) (@sdb9696) +- Add P110 fixture [\#562](https://github.com/python-kasa/python-kasa/pull/562) (@rytilahti) +- Do not do update\(\) in discover\_single [\#542](https://github.com/python-kasa/python-kasa/pull/542) (@sdb9696) +- Add known smart requests to dump\_devinfo [\#597](https://github.com/python-kasa/python-kasa/pull/597) (@sdb9696) + +## [0.5.4](https://github.com/python-kasa/python-kasa/tree/0.5.4) (2023-10-29) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.3...0.5.4) + +The highlights of this maintenance release: + +* Support to the alternative discovery protocol and foundational work to support other communication protocols, thanks to @sdb9696. +* Reliability improvements by avoiding overflowing device buffers, thanks to @cobryan05. +* Optimizations for downstream device accesses, thanks to @bdraco. +* Support for both pydantic v1 and v2. + +As always, see the full changelog for details. + +**Implemented enhancements:** + +- Add a connect\_single method to Discover to avoid the need for UDP [\#528](https://github.com/python-kasa/python-kasa/pull/528) (@bdraco) +- Parse features only during updates [\#527](https://github.com/python-kasa/python-kasa/pull/527) (@bdraco) +- Show an error if both --alias and --host are defined [\#513](https://github.com/python-kasa/python-kasa/pull/513) (@rytilahti) +- Add plumbing for passing credentials to devices [\#507](https://github.com/python-kasa/python-kasa/pull/507) (@sdb9696) +- Add support for pydantic v2 using v1 shims [\#504](https://github.com/python-kasa/python-kasa/pull/504) (@rytilahti) +- Split queries to avoid overflowing device buffers [\#502](https://github.com/python-kasa/python-kasa/pull/502) (@cobryan05) +- Add toggle command to cli [\#498](https://github.com/python-kasa/python-kasa/pull/498) (@normanr) +- Add support for alternative discovery protocol \(20002/udp\) [\#488](https://github.com/python-kasa/python-kasa/pull/488) (@sdb9696) +- Add discovery timeout parameter [\#486](https://github.com/python-kasa/python-kasa/pull/486) (@sdb9696) +- Add devtools script to create module fixtures [\#404](https://github.com/python-kasa/python-kasa/pull/404) (@rytilahti) +- Make timeout adjustable [\#494](https://github.com/python-kasa/python-kasa/pull/494) (@bdraco) + +**Fixed bugs:** + +- Fix on\_since for smartstrip sockets [\#529](https://github.com/python-kasa/python-kasa/pull/529) (@rytilahti) +- Fix every other query tries to fetch known unsupported features [\#520](https://github.com/python-kasa/python-kasa/pull/520) (@bdraco) + +**Documentation updates:** + +- Mark KS2{20}M as partially supported [\#508](https://github.com/python-kasa/python-kasa/pull/508) (@lschweiss) +- Document cli tool --target for discovery [\#497](https://github.com/python-kasa/python-kasa/pull/497) (@rytilahti) + +**Merged pull requests:** + +- Use ruff and ruff format [\#534](https://github.com/python-kasa/python-kasa/pull/534) (@rytilahti) +- Add python3.12 and pypy-3.10 to CI [\#532](https://github.com/python-kasa/python-kasa/pull/532) (@rytilahti) +- Use trusted publisher for publishing to pypi [\#531](https://github.com/python-kasa/python-kasa/pull/531) (@rytilahti) +- Remove code to detect event loop change [\#526](https://github.com/python-kasa/python-kasa/pull/526) (@bdraco) +- Convert readthedocs config to v2 [\#505](https://github.com/python-kasa/python-kasa/pull/505) (@rytilahti) +- Add new HS100\(UK\) fixture [\#489](https://github.com/python-kasa/python-kasa/pull/489) (@sdb9696) +- Update pyproject.toml isort profile, dev group header and poetry.lock [\#487](https://github.com/python-kasa/python-kasa/pull/487) (@sdb9696) + +## [0.5.3](https://github.com/python-kasa/python-kasa/tree/0.5.3) (2023-07-23) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.2...0.5.3) + +This release adds support for defining the device port and introduces dependency on async-timeout which improves timeout handling. + +**Implemented enhancements:** + +- Make device port configurable [\#471](https://github.com/python-kasa/python-kasa/pull/471) (@karpach) + +**Fixed bugs:** + +- Replace asyncio.wait\_for with async-timeout [\#480](https://github.com/python-kasa/python-kasa/pull/480) (@bdraco) + +**Merged pull requests:** + +- Add tests for KP200 [\#483](https://github.com/python-kasa/python-kasa/pull/483) (@bdraco) +- Update pyyaml to fix CI [\#482](https://github.com/python-kasa/python-kasa/pull/482) (@bdraco) + +## [0.5.2](https://github.com/python-kasa/python-kasa/tree/0.5.2) (2023-07-02) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.1...0.5.2) + +Besides some small improvements, this release: +* Adds optional dependency for for `orjson` and `kasa-crypt` to speed-up protocol handling by an order of magnitude. +* Drops Python 3.7 support as it is no longer maintained. + +**Breaking changes:** + +- Drop python 3.7 support [\#455](https://github.com/python-kasa/python-kasa/pull/455) (@rytilahti) + +**Implemented enhancements:** + +- Use orjson when already installed or with speedups extra [\#466](https://github.com/python-kasa/python-kasa/pull/466) (@bdraco) +- Add optional kasa-crypt dependency for speedups [\#464](https://github.com/python-kasa/python-kasa/pull/464) (@bdraco) +- Add inactivity setting for the motion module [\#453](https://github.com/python-kasa/python-kasa/pull/453) (@rytilahti) +- Add methods to configure dimmer settings [\#429](https://github.com/python-kasa/python-kasa/pull/429) (@rytilahti) + +**Fixed bugs:** + +- Request for KP405 Support - Dimmable Plug [\#469](https://github.com/python-kasa/python-kasa/issues/469) +- Issue printing device in on\_discovered: pydantic.error\_wrappers.ValidationError: 3 validation errors for SmartBulbPreset [\#439](https://github.com/python-kasa/python-kasa/issues/439) +- Possible firmware issue with KL125 \(1.0.7 Build 211009 Rel.172044\) [\#345](https://github.com/python-kasa/python-kasa/issues/345) +- Exclude querying certain modules for KL125\(US\) which cause crashes [\#451](https://github.com/python-kasa/python-kasa/pull/451) (@brianthedavis) +- Return result objects for cli discover and implicit 'state' [\#446](https://github.com/python-kasa/python-kasa/pull/446) (@rytilahti) +- Allow effect presets seen on light strips [\#440](https://github.com/python-kasa/python-kasa/pull/440) (@rytilahti) + +**Merged pull requests:** + +- Add benchmarks for speedups [\#473](https://github.com/python-kasa/python-kasa/pull/473) (@bdraco) +- Add fixture for KP405 Smart Dimmer Plug [\#470](https://github.com/python-kasa/python-kasa/pull/470) (@xinud190) +- Remove importlib-metadata dependency [\#457](https://github.com/python-kasa/python-kasa/pull/457) (@rytilahti) +- Update dependencies to fix CI [\#454](https://github.com/python-kasa/python-kasa/pull/454) (@rytilahti) +- Cleanup fixture filenames [\#448](https://github.com/python-kasa/python-kasa/pull/448) (@rytilahti) + +## [0.5.1](https://github.com/python-kasa/python-kasa/tree/0.5.1) (2023-02-18) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.5.0...0.5.1) + +This minor release contains mostly small UX fine-tuning and documentation improvements alongside with bug fixes: +* Improved console tool (JSON output, colorized output if rich is installed) +* Pretty, colorized console output, if `rich` is installed +* Support for configuring bulb presets +* Usage data is now reported in the expected format +* Dependency pinning is relaxed to give downstreams more control + +**Breaking changes:** + +- Implement changing the bulb turn-on behavior [\#381](https://github.com/python-kasa/python-kasa/pull/381) (@rytilahti) + +**Implemented enhancements:** + +- Pretty-print all exceptions from cli commands [\#428](https://github.com/python-kasa/python-kasa/pull/428) (@rytilahti) +- Add transition parameter to lightstrip's set\_effect [\#416](https://github.com/python-kasa/python-kasa/pull/416) (@rytilahti) +- Add brightness to lightstrip's set\_effect [\#415](https://github.com/python-kasa/python-kasa/pull/415) (@rytilahti) +- Use rich for prettier output, if available [\#403](https://github.com/python-kasa/python-kasa/pull/403) (@rytilahti) +- Adding cli command to delete a schedule rule [\#391](https://github.com/python-kasa/python-kasa/pull/391) (@aricforrest) +- Add support for bulb presets [\#379](https://github.com/python-kasa/python-kasa/pull/379) (@rytilahti) +- Add support for json output [\#430](https://github.com/python-kasa/python-kasa/pull/430) (@rytilahti) + +**Fixed bugs:** + +- cli.py usage year and month options do not output data as expected [\#373](https://github.com/python-kasa/python-kasa/issues/373) +- cli.py usage --year command passes year argument incorrectly [\#371](https://github.com/python-kasa/python-kasa/issues/371) +- KP303 reporting as device off [\#319](https://github.com/python-kasa/python-kasa/issues/319) +- HS210 not updating the state correctly [\#193](https://github.com/python-kasa/python-kasa/issues/193) +- Fix year emeter for cli by using kwarg for year parameter [\#372](https://github.com/python-kasa/python-kasa/pull/372) (@rytilahti) +- Return usage.get\_{monthstat,daystat} in expected format [\#394](https://github.com/python-kasa/python-kasa/pull/394) (@jules43) + +**Documentation updates:** + +- Update misleading docs about supported devices \(was: add support for EP25 plug\) [\#367](https://github.com/python-kasa/python-kasa/issues/367) +- Minor fixes to smartbulb docs [\#431](https://github.com/python-kasa/python-kasa/pull/431) (@rytilahti) +- Add a note that transition is not supported by all devices [\#398](https://github.com/python-kasa/python-kasa/pull/398) (@rytilahti) +- fix more outdated CLI examples, remove EP40 from bulb list [\#383](https://github.com/python-kasa/python-kasa/pull/383) (@HankB) +- Fix outdated smartstrip cli examples [\#382](https://github.com/python-kasa/python-kasa/pull/382) (@HankB) +- Add ToCs for doc pages [\#380](https://github.com/python-kasa/python-kasa/pull/380) (@rytilahti) +- Clarify information about supported devices [\#377](https://github.com/python-kasa/python-kasa/pull/377) (@rytilahti) +- Update README to add missing models and fix a link [\#351](https://github.com/python-kasa/python-kasa/pull/351) (@rytilahti) +- Add KP125 test fixture and support note. [\#350](https://github.com/python-kasa/python-kasa/pull/350) (@jalseth) + +**Closed issues:** + +- Add support for setting default behaviors for a soft or hard power on of the bulb [\#365](https://github.com/python-kasa/python-kasa/issues/365) + +**Merged pull requests:** + +- Some release preparation janitoring [\#432](https://github.com/python-kasa/python-kasa/pull/432) (@rytilahti) +- Bump certifi from 2021.10.8 to 2022.12.7 [\#409](https://github.com/python-kasa/python-kasa/pull/409) (@dependabot[bot]) +- Add FUNDING.yml [\#402](https://github.com/python-kasa/python-kasa/pull/402) (@rytilahti) +- Update pre-commit hooks [\#401](https://github.com/python-kasa/python-kasa/pull/401) (@rytilahti) +- Update pre-commit url for flake8 [\#400](https://github.com/python-kasa/python-kasa/pull/400) (@rytilahti) +- Added .gitattributes file to retain LF only EOL markers when checking out on Windows [\#399](https://github.com/python-kasa/python-kasa/pull/399) (@jules43) +- Fix pytest warnings about asyncio [\#397](https://github.com/python-kasa/python-kasa/pull/397) (@jules43) +- Fix type hinting issue with call to click.Choice [\#387](https://github.com/python-kasa/python-kasa/pull/387) (@jules43) +- Manually pass the codecov token in CI [\#378](https://github.com/python-kasa/python-kasa/pull/378) (@rytilahti) +- Correct typos in smartdevice.py [\#358](https://github.com/python-kasa/python-kasa/pull/358) (@felixonmars) +- Add fixtures for KS200M [\#356](https://github.com/python-kasa/python-kasa/pull/356) (@gritstub) +- Add fixtures for KS230 [\#355](https://github.com/python-kasa/python-kasa/pull/355) (@gritstub) +- Add fixtures for ES20M \(\#353\) [\#354](https://github.com/python-kasa/python-kasa/pull/354) (@gritstub) +- Add fixtures for KP100 [\#343](https://github.com/python-kasa/python-kasa/pull/343) (@bdraco) +- Add codeql checks [\#338](https://github.com/python-kasa/python-kasa/pull/338) (@rytilahti) + +## [0.5.0](https://github.com/python-kasa/python-kasa/tree/0.5.0) (2022-04-24) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.3...0.5.0) + +This is the first release of 0.5 series which includes converting the code base towards more modular approach where device-exposed modules (e.g., emeter, antitheft, or schedule) are implemented in their separate python modules to decouple them from the device-specific classes. + +There should be no API breaking changes, but some previous issues hint that there may be as information from all supported modules are now requested during each update cycle (depending on the device type): +* Basic system info +* Emeter +* Time - properties (like `on_since`) use now time from the device for calculation to avoid jitter caused by different time between the host and the device +* Usage statistics - similar interface to emeter, but reports on-time statistics instead of energy consumption (new) +* Countdown (new) +* Antitheft (new) +* Schedule (new) +* Motion - for configuring motion settings on some dimmers (new) +* Ambientlight - for configuring brightness limits when motion sensor actuates on some dimmers (new) +* Cloud - information about cloud connectivity (new) + +For developers, the new functionalities are currently only exposed through the implementation modules accessible through `modules` property. +Pull requests improving the functionality of modules as well as adding better interfaces to device classes are welcome! + +**Breaking changes:** + +- Drop deprecated, type-specific options in favor of --type [\#336](https://github.com/python-kasa/python-kasa/pull/336) (@rytilahti) +- Convert the codebase to be more modular [\#299](https://github.com/python-kasa/python-kasa/pull/299) (@rytilahti) + +**Implemented enhancements:** + +- Improve HS220 support [\#44](https://github.com/python-kasa/python-kasa/issues/44) + +**Fixed bugs:** + +- Skip running discovery on --help on subcommands [\#122](https://github.com/python-kasa/python-kasa/issues/122) +- Avoid retrying open\_connection on unrecoverable errors [\#340](https://github.com/python-kasa/python-kasa/pull/340) (@bdraco) +- Avoid discovery on --help [\#335](https://github.com/python-kasa/python-kasa/pull/335) (@rytilahti) + +**Documentation updates:** + +- Trying to poll device every 5 seconds but getting asyncio errors [\#316](https://github.com/python-kasa/python-kasa/issues/316) +- Docs: Smart Strip - Emeter feature Note [\#257](https://github.com/python-kasa/python-kasa/issues/257) +- Documentation addition: Smartplug access to internet ntp server pool. [\#129](https://github.com/python-kasa/python-kasa/issues/129) +- Export modules & make sphinx happy [\#334](https://github.com/python-kasa/python-kasa/pull/334) (@rytilahti) +- Various documentation updates [\#333](https://github.com/python-kasa/python-kasa/pull/333) (@rytilahti) + +**Merged pull requests:** + +- Add fixtures for kl420 [\#339](https://github.com/python-kasa/python-kasa/pull/339) (@bdraco) + +## [0.4.3](https://github.com/python-kasa/python-kasa/tree/0.4.3) (2022-04-05) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.2...0.4.3) + +**Fixed bugs:** + +- Divide by zero when HS300 powerstrip is discovered [\#292](https://github.com/python-kasa/python-kasa/issues/292) +- Ensure bulb state is restored when turning back on [\#330](https://github.com/python-kasa/python-kasa/pull/330) (@bdraco) + +**Merged pull requests:** + +- Update pre-commit hooks to fix black in CI [\#331](https://github.com/python-kasa/python-kasa/pull/331) (@rytilahti) +- Fix test\_deprecated\_type stalling [\#325](https://github.com/python-kasa/python-kasa/pull/325) (@bdraco) + +## [0.4.2](https://github.com/python-kasa/python-kasa/tree/0.4.2) (2022-03-21) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.1...0.4.2) + +**Implemented enhancements:** + +- Allow environment variables for discovery target, device type and debug [\#313](https://github.com/python-kasa/python-kasa/pull/313) (@rytilahti) +- Add 'internal\_state' to return the results from the last update query [\#306](https://github.com/python-kasa/python-kasa/pull/306) (@rytilahti) +- Drop microsecond precision for on\_since [\#296](https://github.com/python-kasa/python-kasa/pull/296) (@rytilahti) +- Add effect support for light strips [\#293](https://github.com/python-kasa/python-kasa/pull/293) (@bdraco) + +**Fixed bugs:** + +- TypeError: \_\_init\_\_\(\) got an unexpected keyword argument 'package\_name' [\#311](https://github.com/python-kasa/python-kasa/issues/311) +- RuntimeError: Event loop is closed on WSL [\#294](https://github.com/python-kasa/python-kasa/issues/294) +- Don't crash on devices not reporting features [\#317](https://github.com/python-kasa/python-kasa/pull/317) (@rytilahti) + +**Merged pull requests:** + +- Add pyupgrade to CI runs [\#314](https://github.com/python-kasa/python-kasa/pull/314) (@rytilahti) +- Depend on asyncclick \>= 8 [\#312](https://github.com/python-kasa/python-kasa/pull/312) (@rytilahti) +- Guard emeter accesses to avoid keyerrors [\#304](https://github.com/python-kasa/python-kasa/pull/304) (@rytilahti) +- cli: cleanup discover, fetch update prior device access [\#303](https://github.com/python-kasa/python-kasa/pull/303) (@rytilahti) +- Fix unsafe \_\_del\_\_ in TPLinkSmartHomeProtocol [\#300](https://github.com/python-kasa/python-kasa/pull/300) (@bdraco) +- Improve typing for protocol class [\#289](https://github.com/python-kasa/python-kasa/pull/289) (@rytilahti) +- Added a fixture file for KS220M [\#273](https://github.com/python-kasa/python-kasa/pull/273) (@mrbetta) + +## [0.4.1](https://github.com/python-kasa/python-kasa/tree/0.4.1) (2022-01-14) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0...0.4.1) + +**Implemented enhancements:** + +- Add --type option to cli [\#269](https://github.com/python-kasa/python-kasa/pull/269) (@rytilahti) +- Minor improvements to onboarding doc [\#264](https://github.com/python-kasa/python-kasa/pull/264) (@rytilahti) +- Add fixture file for KL135 [\#263](https://github.com/python-kasa/python-kasa/pull/263) (@ErikSGross) +- Add KL135 color temperature range [\#256](https://github.com/python-kasa/python-kasa/pull/256) (@rytilahti) +- Add py.typed to flag that the package is typed [\#251](https://github.com/python-kasa/python-kasa/pull/251) (@rytilahti) +- Add script to check supported devices, update README [\#242](https://github.com/python-kasa/python-kasa/pull/242) (@rytilahti) +- Add perftest to devtools [\#236](https://github.com/python-kasa/python-kasa/pull/236) (@rytilahti) +- Add KP401 US fixture [\#234](https://github.com/python-kasa/python-kasa/pull/234) (@bdraco) +- Add KL60 US KP105 UK fixture [\#233](https://github.com/python-kasa/python-kasa/pull/233) (@bdraco) +- Make cli interface more consistent [\#232](https://github.com/python-kasa/python-kasa/pull/232) (@rytilahti) +- Add KL400, KL50 fixtures [\#231](https://github.com/python-kasa/python-kasa/pull/231) (@bdraco) +- Add fixture for newer KP400 firmware [\#227](https://github.com/python-kasa/python-kasa/pull/227) (@bdraco) +- Switch to poetry-core [\#226](https://github.com/python-kasa/python-kasa/pull/226) (@fabaff) +- Add fixtures for LB110, KL110, EP40, KL430, KP115 [\#224](https://github.com/python-kasa/python-kasa/pull/224) (@bdraco) + +**Fixed bugs:** + +- Discovery on WSL results in OSError: \[Errno 22\] Invalid argument [\#246](https://github.com/python-kasa/python-kasa/issues/246) +- New firmware for HS103 blocking local access? [\#42](https://github.com/python-kasa/python-kasa/issues/42) +- Pin mistune to \<2.0.0 to fix doc builds [\#270](https://github.com/python-kasa/python-kasa/pull/270) (@rytilahti) +- Catch exceptions raised on unknown devices during discovery [\#240](https://github.com/python-kasa/python-kasa/pull/240) (@rytilahti) + +**Merged pull requests:** + +- Publish to pypi on github release published [\#287](https://github.com/python-kasa/python-kasa/pull/287) (@rytilahti) +- Relax asyncclick version requirement [\#286](https://github.com/python-kasa/python-kasa/pull/286) (@rytilahti) +- Do not crash on discovery on WSL [\#283](https://github.com/python-kasa/python-kasa/pull/283) (@rytilahti) +- Add python 3.10 to CI [\#279](https://github.com/python-kasa/python-kasa/pull/279) (@rytilahti) +- Use codecov-action@v2 for CI [\#277](https://github.com/python-kasa/python-kasa/pull/277) (@rytilahti) +- Add coverage\[toml\] dependency to fix coverage on CI [\#271](https://github.com/python-kasa/python-kasa/pull/271) (@rytilahti) +- Allow publish on test pypi workflow to fail [\#248](https://github.com/python-kasa/python-kasa/pull/248) (@rytilahti) + +## [0.4.0](https://github.com/python-kasa/python-kasa/tree/0.4.0) (2021-09-27) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.pre0...0.4.0) + +**Implemented enhancements:** + +- KL430 support [\#67](https://github.com/python-kasa/python-kasa/issues/67) +- Improve retry logic for discovery, messaging \(was: Handle empty responses\) [\#38](https://github.com/python-kasa/python-kasa/issues/38) +- Fix lock being unexpectedly reset on close [\#218](https://github.com/python-kasa/python-kasa/pull/218) (@bdraco) +- Avoid calling pformat unless debug logging is enabled [\#217](https://github.com/python-kasa/python-kasa/pull/217) (@bdraco) +- Keep connection open and lock to prevent duplicate requests [\#213](https://github.com/python-kasa/python-kasa/pull/213) (@bdraco) +- Improve emeterstatus API, move into own module [\#205](https://github.com/python-kasa/python-kasa/pull/205) (@rytilahti) +- Avoid temp array during encrypt and decrypt [\#204](https://github.com/python-kasa/python-kasa/pull/204) (@bdraco) +- Add emeter support for strip sockets [\#203](https://github.com/python-kasa/python-kasa/pull/203) (@bdraco) +- Add own device type for smartstrip children [\#201](https://github.com/python-kasa/python-kasa/pull/201) (@rytilahti) +- bulb: allow set\_hsv without v, add fallback ct range [\#200](https://github.com/python-kasa/python-kasa/pull/200) (@rytilahti) +- Improve bulb support \(alias, time settings\) [\#198](https://github.com/python-kasa/python-kasa/pull/198) (@rytilahti) +- Improve testing harness to allow tests on real devices [\#197](https://github.com/python-kasa/python-kasa/pull/197) (@rytilahti) +- cli: add human-friendly printout when calling temperature on non-supported devices [\#196](https://github.com/python-kasa/python-kasa/pull/196) (@JaydenRA) +- 'Interface' parameter added to discovery process [\#79](https://github.com/python-kasa/python-kasa/pull/79) (@dmitryelj) +- Add support for lightstrips \(KL430\) [\#74](https://github.com/python-kasa/python-kasa/pull/74) (@rytilahti) + +**Fixed bugs:** + +- KL430: Throw error for Device specific information [\#189](https://github.com/python-kasa/python-kasa/issues/189) +- `Unable to find a value for 'current'` error when attempting to query KL125 bulb emeter [\#142](https://github.com/python-kasa/python-kasa/issues/142) +- `Unknown color temperature range` error when attempting to query KL125 bulb state [\#141](https://github.com/python-kasa/python-kasa/issues/141) +- HS300 Children plugs have emeter [\#64](https://github.com/python-kasa/python-kasa/issues/64) +- dump\_devinfo: handle latitude/longitude keys properly [\#175](https://github.com/python-kasa/python-kasa/pull/175) (@rytilahti) +- Simplify discovery query, refactor dump-devinfo [\#147](https://github.com/python-kasa/python-kasa/pull/147) (@rytilahti) +- Return None instead of raising an exception on missing, valid emeter keys [\#146](https://github.com/python-kasa/python-kasa/pull/146) (@rytilahti) +- Simplify device class detection for discovery, fix hardcoded timeout [\#112](https://github.com/python-kasa/python-kasa/pull/112) (@rytilahti) +- Update cli.py to addresss crash on year/month calls and improve output formatting [\#103](https://github.com/python-kasa/python-kasa/pull/103) (@BuongiornoTexas) + +**Documentation updates:** + +- Discover does not support specifying network interface [\#167](https://github.com/python-kasa/python-kasa/issues/167) +- Add ability to control individual sockets on KP400 [\#121](https://github.com/python-kasa/python-kasa/issues/121) +- Improve poetry usage documentation [\#60](https://github.com/python-kasa/python-kasa/issues/60) +- Improve cli documentation for bulbs and power strips [\#123](https://github.com/python-kasa/python-kasa/pull/123) (@rytilahti) + +**Project maintenance:** + +- Add HS220 hw 2.0 fixture [\#107](https://github.com/python-kasa/python-kasa/pull/107) (@appleguru) + +**Merged pull requests:** + +- Add github workflow for pypi publishing [\#220](https://github.com/python-kasa/python-kasa/pull/220) (@rytilahti) +- Add host information to protocol debug logs [\#219](https://github.com/python-kasa/python-kasa/pull/219) (@rytilahti) +- Add KL130 fixture, initial lightstrip tests [\#214](https://github.com/python-kasa/python-kasa/pull/214) (@rytilahti) +- Cleanup discovery & add tests [\#212](https://github.com/python-kasa/python-kasa/pull/212) (@rytilahti) +- More CI fixes [\#208](https://github.com/python-kasa/python-kasa/pull/208) (@rytilahti) +- Fix CI dep installation [\#207](https://github.com/python-kasa/python-kasa/pull/207) (@rytilahti) +- Use github actions instead of azure pipelines [\#206](https://github.com/python-kasa/python-kasa/pull/206) (@rytilahti) +- Add KP115 fixture [\#202](https://github.com/python-kasa/python-kasa/pull/202) (@rytilahti) +- Perform initial update only using the sysinfo query [\#199](https://github.com/python-kasa/python-kasa/pull/199) (@rytilahti) +- Add real kasa KL430\(UN\) device dump [\#192](https://github.com/python-kasa/python-kasa/pull/192) (@iprodanovbg) +- Use less strict matcher for kl430 color temperature [\#190](https://github.com/python-kasa/python-kasa/pull/190) (@rytilahti) +- Add EP10\(US\) 1.0 1.0.2 fixture [\#174](https://github.com/python-kasa/python-kasa/pull/174) (@nbrew) +- Add a note about using the discovery target parameter [\#168](https://github.com/python-kasa/python-kasa/pull/168) (@leandroreox) +- Simplify mac address handling [\#162](https://github.com/python-kasa/python-kasa/pull/162) (@rytilahti) +- Added KL125 and HS200 fixture dumps and updated tests to run on new format [\#160](https://github.com/python-kasa/python-kasa/pull/160) (@brianthedavis) +- Add KL125 bulb definition [\#143](https://github.com/python-kasa/python-kasa/pull/143) (@mdarnol) +- README.md: Add link to MQTT interface for python-kasa [\#140](https://github.com/python-kasa/python-kasa/pull/140) (@flavio-fernandes) +- Fix documentation on Smart strips [\#136](https://github.com/python-kasa/python-kasa/pull/136) (@flavio-fernandes) +- add tapo link, fix tplink-smarthome-simulator link [\#133](https://github.com/python-kasa/python-kasa/pull/133) (@rytilahti) +- Leverage data from UDP discovery to initialize device structure [\#132](https://github.com/python-kasa/python-kasa/pull/132) (@dlee1j1) +- Pin dependencies on major versions, add poetry.lock [\#94](https://github.com/python-kasa/python-kasa/pull/94) (@rytilahti) +- add a small example script to show library usage [\#90](https://github.com/python-kasa/python-kasa/pull/90) (@rytilahti) +- add .readthedocs.yml required for poetry builds [\#89](https://github.com/python-kasa/python-kasa/pull/89) (@rytilahti) +- Improve installation instructions [\#86](https://github.com/python-kasa/python-kasa/pull/86) (@rytilahti) +- cli: Fix incorrect use of asyncio.run for temperature command [\#85](https://github.com/python-kasa/python-kasa/pull/85) (@rytilahti) +- Add parse\_pcap to devtools, improve readme on contributing [\#84](https://github.com/python-kasa/python-kasa/pull/84) (@rytilahti) +- Add --transition to bulb-specific cli commands, fix turn\_{on,off} signatures [\#81](https://github.com/python-kasa/python-kasa/pull/81) (@rytilahti) +- Improve bulb API, force turn on for all light changes as offline changes are not supported [\#76](https://github.com/python-kasa/python-kasa/pull/76) (@rytilahti) +- Simplify API documentation by using doctests [\#73](https://github.com/python-kasa/python-kasa/pull/73) (@rytilahti) +- Bulbs: allow specifying transition for state changes [\#70](https://github.com/python-kasa/python-kasa/pull/70) (@rytilahti) +- Add transition support for SmartDimmer [\#69](https://github.com/python-kasa/python-kasa/pull/69) (@connorproctor) + +## [0.4.0.pre0](https://github.com/python-kasa/python-kasa/tree/0.4.0.pre0) (2020-05-27) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.3.5...0.4.0.pre0) + +**Implemented enhancements:** + +- Add commands to control the wifi settings [\#45](https://github.com/python-kasa/python-kasa/pull/45) (@rytilahti) + +**Fixed bugs:** + +- HSV cli command not working [\#43](https://github.com/python-kasa/python-kasa/issues/43) **Merged pull requests:** -- Add parse\_pcap to devtools, improve readme on contributing [\#84](https://github.com/python-kasa/python-kasa/pull/84) ([rytilahti](https://github.com/rytilahti)) -- Add retries to protocol queries [\#65](https://github.com/python-kasa/python-kasa/pull/65) ([rytilahti](https://github.com/rytilahti)) -- General cleanups all around \(janitoring\) [\#63](https://github.com/python-kasa/python-kasa/pull/63) ([rytilahti](https://github.com/rytilahti)) -- Improve dimmer support [\#62](https://github.com/python-kasa/python-kasa/pull/62) ([rytilahti](https://github.com/rytilahti)) -- Optimize I/O access [\#59](https://github.com/python-kasa/python-kasa/pull/59) ([rytilahti](https://github.com/rytilahti)) -- Remove unnecessary f-string definition to make tests pass [\#58](https://github.com/python-kasa/python-kasa/pull/58) ([rytilahti](https://github.com/rytilahti)) -- Convert to use poetry & pyproject.toml for dep & build management [\#54](https://github.com/python-kasa/python-kasa/pull/54) ([rytilahti](https://github.com/rytilahti)) -- Add fixture for KL60 [\#52](https://github.com/python-kasa/python-kasa/pull/52) ([rytilahti](https://github.com/rytilahti)) -- Ignore D202 where necessary [\#50](https://github.com/python-kasa/python-kasa/pull/50) ([rytilahti](https://github.com/rytilahti)) -- Support wifi scan & join for bulbs using a different interface [\#49](https://github.com/python-kasa/python-kasa/pull/49) ([rytilahti](https://github.com/rytilahti)) -- Return on\_since only when its available and the device is on [\#48](https://github.com/python-kasa/python-kasa/pull/48) ([rytilahti](https://github.com/rytilahti)) -- Allow 0 brightness for smartdimmer [\#47](https://github.com/python-kasa/python-kasa/pull/47) ([rytilahti](https://github.com/rytilahti)) -- async++, small powerstrip improvements [\#46](https://github.com/python-kasa/python-kasa/pull/46) ([rytilahti](https://github.com/rytilahti)) -- Check for emeter support on power strips/multiple plug outlets [\#41](https://github.com/python-kasa/python-kasa/pull/41) ([acmay](https://github.com/acmay)) -- Remove unnecessary cache [\#40](https://github.com/python-kasa/python-kasa/pull/40) ([rytilahti](https://github.com/rytilahti)) -- Add in some new device types [\#39](https://github.com/python-kasa/python-kasa/pull/39) ([acmay](https://github.com/acmay)) -- Add test fixture for KL130 [\#35](https://github.com/python-kasa/python-kasa/pull/35) ([bdraco](https://github.com/bdraco)) -- Move dimmer support to its own class [\#34](https://github.com/python-kasa/python-kasa/pull/34) ([rytilahti](https://github.com/rytilahti)) -- Fix azure pipeline badge [\#32](https://github.com/python-kasa/python-kasa/pull/32) ([rytilahti](https://github.com/rytilahti)) -- Enable Windows & OSX builds [\#31](https://github.com/python-kasa/python-kasa/pull/31) ([rytilahti](https://github.com/rytilahti)) -- Depend on py3.7+ for tox, add python 3.8 to azure pipeline targets [\#29](https://github.com/python-kasa/python-kasa/pull/29) ([rytilahti](https://github.com/rytilahti)) -- Add KP303 to the list of powerstrips [\#28](https://github.com/python-kasa/python-kasa/pull/28) ([rytilahti](https://github.com/rytilahti)) -- Move child socket handling to its own SmartStripPlug class [\#26](https://github.com/python-kasa/python-kasa/pull/26) ([rytilahti](https://github.com/rytilahti)) -- Adding KP303 to supported devices [\#25](https://github.com/python-kasa/python-kasa/pull/25) ([epicalex](https://github.com/epicalex)) -- use pytestmark to avoid repeating asyncio mark [\#24](https://github.com/python-kasa/python-kasa/pull/24) ([rytilahti](https://github.com/rytilahti)) -- Cleanup constructors by removing ioloop and protocol arguments [\#23](https://github.com/python-kasa/python-kasa/pull/23) ([rytilahti](https://github.com/rytilahti)) -- Add \(some\) tests to the cli tool [\#22](https://github.com/python-kasa/python-kasa/pull/22) ([rytilahti](https://github.com/rytilahti)) -- Test against the newly added device fixtures [\#21](https://github.com/python-kasa/python-kasa/pull/21) ([rytilahti](https://github.com/rytilahti)) -- move testing reqs to requirements\_test.txt, add pytest-asyncio for pipelines [\#20](https://github.com/python-kasa/python-kasa/pull/20) ([rytilahti](https://github.com/rytilahti)) -- Remove unused save option and add scrubbing [\#19](https://github.com/python-kasa/python-kasa/pull/19) ([TheGardenMonkey](https://github.com/TheGardenMonkey)) -- Add real kasa device dumps [\#18](https://github.com/python-kasa/python-kasa/pull/18) ([TheGardenMonkey](https://github.com/TheGardenMonkey)) -- Fix dump-discover to use asyncio.run [\#16](https://github.com/python-kasa/python-kasa/pull/16) ([rytilahti](https://github.com/rytilahti)) -- Add device\_id property, rename context to child\_id [\#15](https://github.com/python-kasa/python-kasa/pull/15) ([rytilahti](https://github.com/rytilahti)) -- Remove sync interface, add asyncio discovery [\#14](https://github.com/python-kasa/python-kasa/pull/14) ([rytilahti](https://github.com/rytilahti)) -- Remove --ip which was just an alias to --host [\#6](https://github.com/python-kasa/python-kasa/pull/6) ([rytilahti](https://github.com/rytilahti)) -- Set minimum requirement to python 3.7 [\#5](https://github.com/python-kasa/python-kasa/pull/5) ([rytilahti](https://github.com/rytilahti)) -- change ID of Azure Pipeline [\#3](https://github.com/python-kasa/python-kasa/pull/3) ([basnijholt](https://github.com/basnijholt)) -- Mass rename to \(python-\)kasa [\#1](https://github.com/python-kasa/python-kasa/pull/1) ([rytilahti](https://github.com/rytilahti)) +- Add retries to protocol queries [\#65](https://github.com/python-kasa/python-kasa/pull/65) (@rytilahti) +- General cleanups all around \(janitoring\) [\#63](https://github.com/python-kasa/python-kasa/pull/63) (@rytilahti) +- Improve dimmer support [\#62](https://github.com/python-kasa/python-kasa/pull/62) (@rytilahti) +- Optimize I/O access [\#59](https://github.com/python-kasa/python-kasa/pull/59) (@rytilahti) +- Remove unnecessary f-string definition to make tests pass [\#58](https://github.com/python-kasa/python-kasa/pull/58) (@rytilahti) +- Convert to use poetry & pyproject.toml for dep & build management [\#54](https://github.com/python-kasa/python-kasa/pull/54) (@rytilahti) +- Add fixture for KL60 [\#52](https://github.com/python-kasa/python-kasa/pull/52) (@rytilahti) +- Ignore D202 where necessary [\#50](https://github.com/python-kasa/python-kasa/pull/50) (@rytilahti) +- Support wifi scan & join for bulbs using a different interface [\#49](https://github.com/python-kasa/python-kasa/pull/49) (@rytilahti) +- Return on\_since only when its available and the device is on [\#48](https://github.com/python-kasa/python-kasa/pull/48) (@rytilahti) +- Allow 0 brightness for smartdimmer [\#47](https://github.com/python-kasa/python-kasa/pull/47) (@rytilahti) +- async++, small powerstrip improvements [\#46](https://github.com/python-kasa/python-kasa/pull/46) (@rytilahti) +- Check for emeter support on power strips/multiple plug outlets [\#41](https://github.com/python-kasa/python-kasa/pull/41) (@acmay) +- Remove unnecessary cache [\#40](https://github.com/python-kasa/python-kasa/pull/40) (@rytilahti) +- Add in some new device types [\#39](https://github.com/python-kasa/python-kasa/pull/39) (@acmay) +- Add test fixture for KL130 [\#35](https://github.com/python-kasa/python-kasa/pull/35) (@bdraco) +- Move dimmer support to its own class [\#34](https://github.com/python-kasa/python-kasa/pull/34) (@rytilahti) +- Fix azure pipeline badge [\#32](https://github.com/python-kasa/python-kasa/pull/32) (@rytilahti) +- Enable Windows & OSX builds [\#31](https://github.com/python-kasa/python-kasa/pull/31) (@rytilahti) +- Depend on py3.7+ for tox, add python 3.8 to azure pipeline targets [\#29](https://github.com/python-kasa/python-kasa/pull/29) (@rytilahti) +- Add KP303 to the list of powerstrips [\#28](https://github.com/python-kasa/python-kasa/pull/28) (@rytilahti) +- Move child socket handling to its own SmartStripPlug class [\#26](https://github.com/python-kasa/python-kasa/pull/26) (@rytilahti) +- Adding KP303 to supported devices [\#25](https://github.com/python-kasa/python-kasa/pull/25) (@epicalex) +- use pytestmark to avoid repeating asyncio mark [\#24](https://github.com/python-kasa/python-kasa/pull/24) (@rytilahti) +- Cleanup constructors by removing ioloop and protocol arguments [\#23](https://github.com/python-kasa/python-kasa/pull/23) (@rytilahti) +- Add \(some\) tests to the cli tool [\#22](https://github.com/python-kasa/python-kasa/pull/22) (@rytilahti) +- Test against the newly added device fixtures [\#21](https://github.com/python-kasa/python-kasa/pull/21) (@rytilahti) +- move testing reqs to requirements\_test.txt, add pytest-asyncio for pipelines [\#20](https://github.com/python-kasa/python-kasa/pull/20) (@rytilahti) +- Remove unused save option and add scrubbing [\#19](https://github.com/python-kasa/python-kasa/pull/19) (@TheGardenMonkey) +- Add real kasa device dumps [\#18](https://github.com/python-kasa/python-kasa/pull/18) (@TheGardenMonkey) +- Fix dump-discover to use asyncio.run [\#16](https://github.com/python-kasa/python-kasa/pull/16) (@rytilahti) +- Add device\_id property, rename context to child\_id [\#15](https://github.com/python-kasa/python-kasa/pull/15) (@rytilahti) +- Remove sync interface, add asyncio discovery [\#14](https://github.com/python-kasa/python-kasa/pull/14) (@rytilahti) +- Remove --ip which was just an alias to --host [\#6](https://github.com/python-kasa/python-kasa/pull/6) (@rytilahti) +- Set minimum requirement to python 3.7 [\#5](https://github.com/python-kasa/python-kasa/pull/5) (@rytilahti) +- change ID of Azure Pipeline [\#3](https://github.com/python-kasa/python-kasa/pull/3) (@basnijholt) +- Mass rename to \(python-\)kasa [\#1](https://github.com/python-kasa/python-kasa/pull/1) (@rytilahti) Historical pyHS100 changelog ============================ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..b6d851f9c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,4 @@ +# Contributing to python-kasa + +All types of contributions are very welcome. +To make the process as straight-forward as possible, we have written [some instructions in our docs](https://python-kasa.readthedocs.io/en/latest/contribute.html) to get you started. diff --git a/README.md b/README.md index 09329ef18..f23922308 100644 --- a/README.md +++ b/README.md @@ -1,147 +1,242 @@ # python-kasa [![PyPI version](https://badge.fury.io/py/python-kasa.svg)](https://badge.fury.io/py/python-kasa) -[![Build Status](https://dev.azure.com/python-kasa/python-kasa/_apis/build/status/python-kasa.python-kasa?branchName=master)](https://dev.azure.com/python-kasa/python-kasa/_build/latest?definitionId=2&branchName=master) -[![Coverage Status](https://coveralls.io/repos/github/python-kasa/python-kasa/badge.svg?branch=master)](https://coveralls.io/github/python-kasa/python-kasa?branch=master) +[![Build Status](https://github.com/python-kasa/python-kasa/actions/workflows/ci.yml/badge.svg)](https://github.com/python-kasa/python-kasa/actions/workflows/ci.yml) +[![codecov](https://codecov.io/gh/python-kasa/python-kasa/branch/master/graph/badge.svg?token=5K7rtN5OmS)](https://codecov.io/gh/python-kasa/python-kasa) [![Documentation Status](https://readthedocs.org/projects/python-kasa/badge/?version=latest)](https://python-kasa.readthedocs.io/en/latest/?badge=latest) -python-kasa is a Python library to control TPLink smart home devices (plugs, wall switches, power strips, and bulbs) using asyncio. -This project is a maintainer-made fork of [pyHS100](https://github.com/GadgetReactor/pyHS100) project. +python-kasa is a Python library to control TPLink's smart home devices (plugs, wall switches, power strips, and bulbs). + +This is a voluntary, community-driven effort and is not affiliated, sponsored, or endorsed by TPLink. + +**Contributions in any form (adding missing features, reporting issues, fixing or triaging existing ones, improving the documentation, or device donations) are more than welcome!** + +--- ## Getting started -You can install the most recent release using pip. Until +You can install the most recent release using pip: ``` -pip install python-kasa --pre +pip install python-kasa ``` -Alternatively, you can clone this repository and use poetry to install the development version: +Alternatively, you can clone this repository and use `uv` to install the development version: ``` git clone https://github.com/python-kasa/python-kasa.git cd python-kasa/ -poetry install +uv sync --all-extras +uv run kasa ``` +If you have not yet provisioned your device, [you can do so using the cli tool](https://python-kasa.readthedocs.io/en/latest/cli.html#provisioning). + ## Discovering devices -After installation, the devices can be discovered either by using `kasa discover` or by calling `kasa` without any parameters. +Running `kasa discover` will send discovery packets to the default broadcast address (`255.255.255.255`) to discover supported devices. +If your device requires authentication to control it, +you need to pass the credentials using `--username` and `--password` options or define `KASA_USERNAME` and `KASA_PASSWORD` environment variables. + +> [!NOTE] +> If your system has multiple network interfaces, you can specify the broadcast address using the `--target` option. + +The `discover` command will automatically execute the `state` command on all the discovered devices: + +``` +$ kasa discover +Discovering devices on 255.255.255.255 for 3 seconds + +== Bulb McBulby - L530 == +Host: 192.0.2.123 +Port: 80 +Device state: False +Time: 2024-06-22 15:42:15+02:00 (tz: {'timezone': 'CEST'} +Hardware: 3.0 +Software: 1.1.6 Build 240130 Rel.173828 +MAC (rssi): 5C:E9:31:aa:bb:cc (-50) +== Primary features == +State (state): False +Brightness (brightness): 11 (range: 0-100) +Color temperature (color_temperature): 0 (range: 2500-6500) +Light effect (light_effect): *Off* Party Relax + +== Information == +Signal Level (signal_level): 2 +Overheated (overheated): False +Cloud connection (cloud_connection): False +Update available (update_available): None +Device time (device_time): 2024-06-22 15:42:15+02:00 + +== Configuration == +HSV (hsv): HSV(hue=35, saturation=70, value=11) +Auto update enabled (auto_update_enabled): False +Light preset (light_preset): *Not set* Light preset 1 Light preset 2 Light preset 3 Light preset 4 Light preset 5 Light preset 6 Light preset 7 +Smooth transition on (smooth_transition_on): 2 (range: 0-60) +Smooth transition off (smooth_transition_off): 20 (range: 0-60) + +== Debug == +Device ID (device_id): soneuniqueidentifier +RSSI (rssi): -50 dBm +SSID (ssid): HomeNet +Current firmware version (current_firmware_version): 1.1.6 Build 240130 Rel.173828 +Available firmware version (available_firmware_version): None +``` + + +## Command line usage + +All devices support a variety of common commands (like `on`, `off`, and `state`). +The syntax to control device is `kasa --host `: ``` -$ kasa -No --bulb nor --plug given, discovering.. -Discovering devices for 3 seconds -== My Smart Plug - HS110(EU) == -Device state: ON -IP address: 192.168.x.x -LED state: False -On since: 2017-03-26 18:29:17.242219 -== Generic information == -Time: 1970-06-22 02:39:41 -Hardware: 1.0 -Software: 1.0.8 Build 151101 Rel.24452 -MAC (rssi): 50:C7:BF:XX:XX:XX (-77) -Location: {'latitude': XXXX, 'longitude': XXXX} -== Emeter == -Current state: {'total': 133.082, 'power': 100.418681, 'current': 0.510967, 'voltage': 225.600477} +$ kasa --host 192.0.2.123 on ``` -Use `kasa --help` to get list of all available commands, or alternatively, [consult the documentation](https://python-kasa.readthedocs.io/en/latest/cli.html). +Use `kasa --help` ([or consult the documentation](https://python-kasa.readthedocs.io/en/latest/cli.html#kasa-help)) to get a list of all available commands and options. +Some examples of available options include JSON output (`--json`), more verbose output (`--verbose`), and defining timeouts (`--timeout` and `--discovery-timeout`). +Refer [the documentation](https://python-kasa.readthedocs.io/en/latest/cli.html) for more details. -## Basic controls +> [!NOTE] +> Each individual command may also have additional options, which are shown when called with the `--help` option. -All devices support a variety of common commands, including: - * `state` which returns state information - * `on` and `off` for turning the device on or off - * `emeter` (where applicable) to return energy consumption information - * `sysinfo` to return raw system information -## Energy meter +### Feature interface -Passing no options to `emeter` command will return the current consumption. -Possible options include `--year` and `--month` for retrieving historical state, -and reseting the counters is done with `--erase`. +All devices are also controllable through a generic feature-based interface. +The available features differ from device to device and are accessible using `kasa feature` command: ``` -$ kasa emeter -== Emeter == -Current state: {'total': 133.105, 'power': 108.223577, 'current': 0.54463, 'voltage': 225.296283} +$ kasa --host 192.0.2.123 feature +== Primary features == +State (state): False +Brightness (brightness): 11 (range: 0-100) +Color temperature (color_temperature): 0 (range: 2500-6500) +Light effect (light_effect): *Off* Party Relax + +== Information == +Signal Level (signal_level): 2 +Overheated (overheated): False +Cloud connection (cloud_connection): False +Update available (update_available): None +Device time (device_time): 2024-06-22 15:39:44+02:00 + +== Configuration == +HSV (hsv): HSV(hue=35, saturation=70, value=11) +Auto update enabled (auto_update_enabled): False +Light preset (light_preset): *Not set* Light preset 1 Light preset 2 Light preset 3 Light preset 4 Light preset 5 Light preset 6 Light preset 7 +Smooth transition on (smooth_transition_on): 2 (range: 0-60) +Smooth transition off (smooth_transition_off): 20 (range: 0-60) + +== Debug == +Device ID (device_id): soneuniqueidentifier +RSSI (rssi): -50 dBm +SSID (ssid): HomeNet +Current firmware version (current_firmware_version): 1.1.6 Build 240130 Rel.173828 +Available firmware version (available_firmware_version): None ``` -## Bulb-specific commands +Some features present configuration that can be changed: +``` +kasa --host 192.0.2.123 feature color_temperature 2500 +Changing color_temperature from 0 to 2500 +New state: 2500 +``` -At the moment setting brightness, color temperature and color (in HSV) are supported depending on the device. -The commands are straightforward, so feel free to check `--help` for instructions how to use them. +> [!NOTE] +> When controlling hub-connected devices, you need to pass the device ID of the connected device as an option: `kasa --host 192.0.2.200 feature --child someuniqueidentifier target_temperature 21` -# Library usage -You can find several code examples in [the API documentation](https://python-kasa.readthedocs.io). +## Library usage -## Contributing +``` +import asyncio +from kasa import Discover -Contributions are very welcome! To simplify the process, we are leveraging automated checks and tests for contributions. +async def main(): + dev = await Discover.discover_single("192.0.2.123", username="un@example.com", password="pw") + await dev.turn_on() + await dev.update() -### Setting up development environment +if __name__ == "__main__": + asyncio.run(main()) +``` -To get started, simply clone this repository and initialize the development environment. -We are using [poetry](https://python-poetry.org) for dependency management, so after cloning the repository simply execute -`poetry install` which will install all necessary packages and create a virtual environment for you. +If you want to use this library in your own project, a good starting point is [the tutorial in the documentation](https://python-kasa.readthedocs.io/en/latest/tutorial.html). -### Code-style checks +You can find several code examples in the API documentation [How to guides](https://python-kasa.readthedocs.io/en/latest/guides.html). -We use several tools to automatically check all contributions. The simplest way to verify that everything is formatted properly -before creating a pull request, consider activating the pre-commit hooks by executing `pre-commit install`. -This will make sure that the checks are passing when you do a commit. +Information about the library design and the way the devices work can be found in the [topics section](https://python-kasa.readthedocs.io/en/latest/topics.html). -You can also execute the checks by running either `tox -e lint` to only do the linting checks, or `tox` to also execute the tests. +## Contributing -### Analyzing network captures +Contributions are very welcome! The easiest way to contribute is by [creating a fixture file](https://python-kasa.readthedocs.io/en/latest/contribute.html#contributing-fixture-files) for the automated test suite if your device hardware and firmware version is not currently listed as supported. +Please refer to [our contributing guidelines](https://python-kasa.readthedocs.io/en/latest/contribute.html). -The simplest way to add support for a new device or to improve existing ones is to capture traffic between the mobile app and the device. -After capturing the traffic, you can either use the [softScheck's wireshark dissector](https://github.com/softScheck/tplink-smartplug#wireshark-dissector) -or the `parse_pcap.py` script contained inside the `devtools` directory. +## Supported devices +The following devices have been tested and confirmed as working. If your device is unlisted but working, please consider [contributing a fixture file](https://python-kasa.readthedocs.io/en/latest/contribute.html#contributing-fixture-files). -## Supported devices +> [!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 +- **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, 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] -* HS100 -* HS103 -* HS105 -* HS107 -* HS110 +### Supported Tapo[^1] devices -### Power Strips +- **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, TS15 +- **Bulbs**: L430C, L430P, L510B, L510E, L530B, L530E, L535E, L630 +- **Light Strips**: L900-10, L900-5, L920-5, L930-5 +- **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 -* HS300 -* KP303 + +[^1]: Model requires authentication +[^2]: Newer versions require authentication +[^3]: Devices may work across TAPO/KASA branded hubs -### Wall switches +See [supported devices in our documentation](SUPPORTED.md) for more detailed information about tested hardware and software versions. -* HS200 -* HS210 -* HS220 +## Resources -### Bulbs +### Developer Resources -* LB100 -* LB110 -* LB120 -* LB130 -* LB230 -* KL60 -* KL110 -* KL120 -* KL130 +* [softScheck's github contains lot of information and wireshark dissector](https://github.com/softScheck/tplink-smartplug) +* [TP-Link Smart Home Device Simulator](https://github.com/plasticrake/tplink-smarthome-simulator) +* [Unofficial API documentation](https://github.com/plasticrake/tplink-smarthome-api) +* [Another unofficial API documentation](https://github.com/whitslack/kasa) +* [pyHS100](https://github.com/GadgetReactor/pyHS100) provides synchronous interface and is the unmaintained predecessor of this library. -### Light strips -* KL430 +### Library Users -**Contributions (be it adding missing features, fixing bugs or improving documentation) are more than welcome, feel free to submit pull requests!** +* [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) -### Resources +### Other related projects -* [softScheck's github contains lot of information and wireshark dissector](https://github.com/softScheck/tplink-smartplug#wireshark-dissector) -* [https://github.com/plasticrake/tplink-smarthome-simulator](tplink-smarthome-simulator) -* [Unofficial API documentation](https://github.com/plasticrake/tplink-smarthome-api/blob/master/API.md) +* [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) + * [Home Assistant integration](https://github.com/petretiandrea/home-assistant-tapo-p100) +* [rust and python implementation for tapo devices](https://github.com/mihai-dinculescu/tapo/) diff --git a/RELEASING.md b/RELEASING.md index 75a775edb..b5587d601 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,63 +1,320 @@ -1. Set release information +# Releasing + +## Requirements +* [github client](https://github.com/cli/cli#installation) +* [gitchub_changelog_generator](https://github.com/github-changelog-generator) +* [github access token](https://github.com/github-changelog-generator/github-changelog-generator#github-token) + +## Export changelog token ```bash -# export PREVIOUS_RELEASE=$(git describe --abbrev=0) -export PREVIOUS_RELEASE=0.3.5 # generate the full changelog since last pyhs100 release -export NEW_RELEASE=0.4.0.pre1 +export CHANGELOG_GITHUB_TOKEN=token ``` -2. Update the version number +## Set release information + +0.3.5 should always be the previous release as it's the last pyhs100 release in HISTORY.md which is the changelog prior to github release notes. ```bash -poetry version $NEW_RELEASE +export NEW_RELEASE=x.x.x.devx ``` -3. Generate changelog +## Normal releases from master + +### Create a branch for the release ```bash -# gem install github_changelog_generator --pre -# https://github.com/github-changelog-generator/github-changelog-generator#github-token -export CHANGELOG_GITHUB_TOKEN=token -github_changelog_generator --base HISTORY.md --user python-kasa --project python-kasa --since-tag $PREVIOUS_RELEASE --future-release $NEW_RELEASE -o CHANGELOG.md +git checkout master +git fetch upstream master +git rebase upstream/master +git checkout -b release/$NEW_RELEASE +``` + +### Update the version number + +```bash +sed -i "0,/version = /{s/version = .*/version = \"${NEW_RELEASE}\"/}" pyproject.toml ``` -3. Write a short and understandable summary for the release. +### Update dependencies -4. Commit the changed files +```bash +uv sync --all-extras +uv lock --upgrade +uv sync --all-extras +``` + +### Update and run pre-commit and tests ```bash -git commit -av +pre-commit autoupdate +uv run pre-commit run --all-files +uv run pytest -n auto ``` -5. Create a PR for the release. +### Create release summary (skip for dev releases) + +Write a short and understandable summary for the release. Can include images. + +#### Create $NEW_RELEASE milestone in github + +If not already created -6. Get it merged, fetch the upstream master +#### Create new issue linked to the milestone + +```bash +gh issue create --label "release-summary" --milestone $NEW_RELEASE --title "$NEW_RELEASE Release Summary" --body "**Release summary:**" +``` + +You can exclude the --body option to get an interactive editor or go into the issue on github and edit there. + +#### Close the issue + +Either via github or: + +```bash +gh issue close ISSUE_NUMBER +``` + +### Generate changelog + +Configuration settings are in `.github_changelog_generator` + +#### For pre-release + +EXCLUDE_TAGS will exclude all dev tags except for the current release dev tags. + +Regex should be something like this `^((?!0\.7\.0)(.*dev\d))+`. The first match group negative matches on the current release and the second matches on releases ending with dev. + +```bash +EXCLUDE_TAGS=${NEW_RELEASE%.dev*}; EXCLUDE_TAGS=${EXCLUDE_TAGS//"."/"\."}; EXCLUDE_TAGS="^((?!"$EXCLUDE_TAGS")(.*dev\d))+" +echo "$EXCLUDE_TAGS" +github_changelog_generator --future-release $NEW_RELEASE --exclude-tags-regex "$EXCLUDE_TAGS" +``` + +#### For production + +```bash +github_changelog_generator --future-release $NEW_RELEASE --exclude-tags-regex 'dev\d$' +``` + +You can ignore warnings about missing PR commits like below as these relate to PRs to branches other than master: +``` +Warning: PR 908 merge commit was not found in the release branch or tagged git history and no rebased SHA comment was found +``` + + +### Export new release notes to variable + +```bash +export RELEASE_NOTES=$(grep -Poz '(?<=\# Changelog\n\n)(.|\n)+?(?=\#\#)' CHANGELOG.md | tr '\0' '\n' ) +echo "$RELEASE_NOTES" # Check the output and copy paste if neccessary +``` + +### Commit and push the changed files + +```bash +git commit --all --verbose -m "Prepare $NEW_RELEASE" +git push upstream release/$NEW_RELEASE -u +``` + +### Create a PR for the release, merge it, and re-fetch the master + +#### Create the PR +``` +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. + +```bash +gh pr merge --squash --body "$RELEASE_NOTES" +``` + +### Rebase local master ```bash git checkout master -git fetch upstream +git fetch upstream master git rebase upstream/master ``` -5. Tag the release (add short changelog as a tag commit message), push the tag to git +### Create a release tag + +Note, add changelog release notes as the tag commit message so `gh release create --notes-from-tag` can be used to create a release draft. + +```bash +git tag --annotate $NEW_RELEASE -m "$RELEASE_NOTES" +git push upstream $NEW_RELEASE +``` + +### Create release + +#### Pre-releases + +```bash +gh release create "$NEW_RELEASE" --verify-tag --notes-from-tag --title "$NEW_RELEASE" --draft --latest=false --prerelease + +``` + +#### Production release + +```bash +gh release create "$NEW_RELEASE" --verify-tag --notes-from-tag --title "$NEW_RELEASE" --draft --latest=true +``` + +### Manually publish the release + +Go to the linked URL, verify the contents, and click "release" button to trigger the release CI. + +## Patch releases + +This requires git commit signing to be enabled. + +https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification + +### Create release branch + +#### For the first patch release since a new release only + +```bash +export NEW_RELEASE=x.x.x.x +export CURRENT_RELEASE=x.x.x +``` + +```bash +git fetch upstream $CURRENT_RELEASE +git checkout patch +git fetch upstream patch +git rebase upstream/patch +git fetch upstream $CURRENT_RELEASE +git merge $CURRENT_RELEASE --ff-only +git push upstream patch -u +git checkout -b release/$NEW_RELEASE +``` + +#### For subsequent patch releases + +```bash +export NEW_RELEASE=x.x.x.x +``` + +```bash +git checkout patch +git fetch upstream patch +git rebase upstream/patch +git checkout -b release/$NEW_RELEASE +``` +### Cherry pick required commits + +```bash +git cherry-pick commitSHA1 -S +git cherry-pick commitSHA2 -S +``` + +### Update the version number ```bash -git tag -a $NEW_RELEASE +sed -i "0,/version = /{s/version = .*/version = \"${NEW_RELEASE}\"/}" pyproject.toml +``` + +### Manually edit the changelog + +github_changlog generator_does not work with patch releases so manually add the section for the new release to CHANGELOG.md. + +### Export new release notes to variable + +```bash +export RELEASE_NOTES=$(grep -Poz '(?<=\# Changelog\n\n)(.|\n)+?(?=\#\#)' CHANGELOG.md | tr '\0' '\n' ) +echo "$RELEASE_NOTES" # Check the output and copy paste if neccessary +``` + +### Commit and push the changed files + +```bash +git commit --all --verbose -m "Prepare $NEW_RELEASE" -S +git push upstream release/$NEW_RELEASE -u +``` + +### Create a PR for the release, merge it, and re-fetch patch + +#### Create the PR +``` +gh pr create --title "$NEW_RELEASE" --body "$RELEASE_NOTES" --label release-prep --base patch +``` + +#### Merge the PR once the CI passes + +Create a **merge** commit and add the markdown from the PR description to the commit description. + +```bash +gh pr merge --merge --body "$RELEASE_NOTES" +``` + +### Rebase local patch + +```bash +git checkout patch +git fetch upstream patch +git rebase upstream/patch +``` + +### Create a release tag + +```bash +git tag -s --annotate $NEW_RELEASE -m "$RELEASE_NOTES" git push upstream $NEW_RELEASE ``` -7. Upload new version to pypi +### Create release -If not done already, create an API key for pypi (https://pypi.org/manage/account/token/) and configure it: +```bash +gh release create "$NEW_RELEASE" --verify-tag --notes-from-tag --title "$NEW_RELEASE" --draft --latest=true ``` -poetry config pypi-token.pypi +Then go into github, review and release + +### Merge patch back to master + +```bash +git checkout master +git fetch upstream master +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 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 +git push upstream janitor/merge_patch -u +gh pr create --title "Merge patch into master" --body '' --label release-prep --base master ``` -To build & release: +#### Temporarily allow merge commits to master + +1. Open [repository settings](https://github.com/python-kasa/python-kasa/settings) +2. From the left select `Rules` > `Rulesets` +3. Open `master` ruleset, under `Bypass list` select `+ Add bypass` +4. Check `Repository admin` > `Add selected`, select `Save changes` +#### Merge commit the PR ```bash -poetry build -poetry publish +gh pr merge --merge --body "" ``` +#### Revert allow merge commits -8. Click the "Draft a new release" button on github, select the new tag and copy & paste the changelog into the description. +1. Under `Bypass list` select `...` next to `Repository admins` +2. `Delete bypass`, select `Save changes` diff --git a/SUPPORTED.md b/SUPPORTED.md new file mode 100644 index 000000000..900c5ef97 --- /dev/null +++ b/SUPPORTED.md @@ -0,0 +1,388 @@ +# Supported devices + +The following devices have been tested and confirmed as working. If your device is unlisted but working, please open a pull request to update the list and add a fixture file (use `python -m devtools.dump_devinfo` to generate one). + +> [!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. + + + +## Kasa devices + +Some newer Kasa devices require authentication. These are marked with [^1] in the list below.
Hub-Connected Devices may work across TAPO/KASA branded hubs even if they don't work across the native apps. + +### Plugs + +- **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** + - Hardware: 1.0 (UK) / Firmware: 1.2.6 + - Hardware: 4.1 (UK) / Firmware: 1.1.0[^1] + - Hardware: 1.0 (US) / Firmware: 1.2.5 + - Hardware: 2.0 (US) / Firmware: 1.5.6 +- **HS103** + - Hardware: 1.0 (US) / Firmware: 1.5.7 + - Hardware: 2.1 (US) / Firmware: 1.1.2 + - Hardware: 2.1 (US) / Firmware: 1.1.4 +- **HS105** + - Hardware: 1.0 (US) / Firmware: 1.5.6 +- **HS110** + - Hardware: 1.0 (EU) / Firmware: 1.2.5 + - Hardware: 4.0 (EU) / Firmware: 1.0.4 + - Hardware: 1.0 (US) / Firmware: 1.2.6 +- **KP100** + - Hardware: 3.0 (US) / Firmware: 1.0.1 +- **KP105** + - Hardware: 1.0 (UK) / Firmware: 1.0.5 + - Hardware: 1.0 (UK) / Firmware: 1.0.7 +- **KP115** + - Hardware: 1.0 (EU) / Firmware: 1.0.16 + - Hardware: 1.0 (US) / Firmware: 1.0.17 + - Hardware: 1.0 (US) / Firmware: 1.0.21 +- **KP125** + - Hardware: 1.0 (US) / Firmware: 1.0.6 +- **KP125M** + - Hardware: 1.0 (US) / Firmware: 1.1.3[^1] + - Hardware: 1.0 (US) / Firmware: 1.2.3[^1] +- **KP401** + - Hardware: 1.0 (US) / Firmware: 1.0.0 + +### Power Strips + +- **EP40** + - Hardware: 1.0 (US) / Firmware: 1.0.2 +- **EP40M** + - Hardware: 1.0 (US) / Firmware: 1.1.0[^1] +- **HS107** + - Hardware: 1.0 (US) / Firmware: 1.0.8 +- **HS300** + - Hardware: 1.0 (US) / Firmware: 1.0.10 + - Hardware: 1.0 (US) / Firmware: 1.0.21 + - Hardware: 2.0 (US) / Firmware: 1.0.12 + - Hardware: 2.0 (US) / Firmware: 1.0.3 +- **KP200** + - Hardware: 3.0 (US) / Firmware: 1.0.3 +- **KP303** + - Hardware: 1.0 (UK) / Firmware: 1.0.3 + - Hardware: 2.0 (US) / Firmware: 1.0.3 + - Hardware: 2.0 (US) / Firmware: 1.0.9 +- **KP400** + - Hardware: 1.0 (US) / Firmware: 1.0.10 + - Hardware: 2.0 (US) / Firmware: 1.0.6 + - Hardware: 3.0 (US) / Firmware: 1.0.3 + - Hardware: 3.0 (US) / Firmware: 1.0.4 + +### Wall Switches + +- **ES20M** + - Hardware: 1.0 (US) / Firmware: 1.0.11 + - Hardware: 1.0 (US) / Firmware: 1.0.8 +- **HS200** + - Hardware: 2.0 (US) / Firmware: 1.5.7 + - Hardware: 3.0 (US) / Firmware: 1.1.5 + - Hardware: 5.0 (US) / Firmware: 1.0.11 + - Hardware: 5.0 (US) / Firmware: 1.0.2 + - Hardware: 5.26 (US) / Firmware: 1.0.3[^1] +- **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 + - Hardware: 3.26 (US) / Firmware: 1.0.1[^1] +- **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 + - Hardware: 1.0 (US) / Firmware: 1.0.12 + - Hardware: 1.0 (US) / Firmware: 1.0.8 +- **KS205** + - Hardware: 1.0 (US) / Firmware: 1.0.2[^1] + - Hardware: 1.0 (US) / Firmware: 1.1.0[^1] +- **KS220** + - Hardware: 1.0 (US) / Firmware: 1.0.13 +- **KS220M** + - Hardware: 1.0 (US) / Firmware: 1.0.4 +- **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] + - Hardware: 1.0 (US) / Firmware: 1.0.7[^1] + +### Bulbs + +- **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 +- **KL125** + - Hardware: 1.20 (US) / Firmware: 1.0.5 + - Hardware: 2.0 (US) / Firmware: 1.0.7 + - Hardware: 4.0 (US) / Firmware: 1.0.5 +- **KL130** + - Hardware: 1.0 (EU) / Firmware: 1.8.8 + - Hardware: 1.0 (US) / Firmware: 1.8.11 +- **KL135** + - Hardware: 1.0 (US) / Firmware: 1.0.15 + - Hardware: 1.0 (US) / Firmware: 1.0.6 +- **KL50** + - Hardware: 1.0 (US) / Firmware: 1.1.13 +- **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.5 + - Hardware: 1.0 (US) / Firmware: 1.0.8 +- **KL420L5** + - Hardware: 1.0 (US) / Firmware: 1.0.2 +- **KL430** + - Hardware: 2.0 (UN) / Firmware: 1.0.8 + - Hardware: 1.0 (US) / Firmware: 1.0.10 + - Hardware: 2.0 (US) / Firmware: 1.0.11 + - Hardware: 2.0 (US) / Firmware: 1.0.8 + - Hardware: 2.0 (US) / Firmware: 1.0.9 + +### Hubs + +- **KH100** + - Hardware: 1.0 (EU) / Firmware: 1.2.3[^1] + - Hardware: 1.0 (EU) / Firmware: 1.5.12[^1] + - Hardware: 1.0 (UK) / Firmware: 1.5.6[^1] + +### Hub-Connected Devices + +- **KE100** + - Hardware: 1.0 (EU) / Firmware: 2.4.0[^1] + - Hardware: 1.0 (EU) / Firmware: 2.8.0[^1] + - Hardware: 1.0 (UK) / Firmware: 2.8.0[^1] + + +## Tapo devices + +All Tapo devices require authentication.
Hub-Connected Devices may work across TAPO/KASA branded hubs even if they don't work across the native apps. + +### Plugs + +- **P100** + - 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 +- **P110M** + - Hardware: 1.0 (AU) / Firmware: 1.2.3 + - 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 +- **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 +- **L535E** + - Hardware: 3.0 (EU) / Firmware: 1.1.8 +- **L630** + - Hardware: 1.0 (EU) / Firmware: 1.1.2 + +### Light Strips + +- **L900-10** + - Hardware: 1.0 (EU) / Firmware: 1.0.17 + - Hardware: 1.0 (US) / Firmware: 1.0.11 +- **L900-5** + - Hardware: 1.0 (EU) / Firmware: 1.0.17 + - Hardware: 1.0 (EU) / Firmware: 1.1.0 +- **L920-5** + - Hardware: 1.0 (EU) / Firmware: 1.0.7 + - Hardware: 1.0 (EU) / Firmware: 1.1.3 + - 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 + +- **S200B** + - Hardware: 1.0 (EU) / Firmware: 1.11.0 + - Hardware: 1.0 (US) / Firmware: 1.12.0 +- **S200D** + - Hardware: 1.0 (EU) / Firmware: 1.11.0 + - 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 + - Hardware: 1.0 (US) / Firmware: 1.9.0 +- **T300** + - Hardware: 1.0 (EU) / Firmware: 1.7.0 +- **T310** + - Hardware: 1.0 (EU) / Firmware: 1.5.0 + - Hardware: 1.0 (US) / Firmware: 1.5.0 +- **T315** + - Hardware: 1.0 (EU) / Firmware: 1.7.0 + - Hardware: 1.0 (US) / Firmware: 1.8.0 + + + +[^1]: Model requires authentication diff --git a/azure-pipelines.yml b/azure-pipelines.yml deleted file mode 100644 index 2b201ae08..000000000 --- a/azure-pipelines.yml +++ /dev/null @@ -1,118 +0,0 @@ -trigger: -- master -pr: -- master - - -stages: -- stage: "Linting" - jobs: - - job: "LintChecks" - pool: - vmImage: "ubuntu-latest" - strategy: - matrix: - Python 3.8: - python.version: '3.8' - steps: - - task: UsePythonVersion@0 - inputs: - versionSpec: '$(python.version)' - displayName: 'Use Python $(python.version)' - - - script: | - python -m pip install --upgrade pip poetry - poetry install - displayName: 'Install dependencies' - - - script: | - poetry run pre-commit run black --all-files - displayName: 'Code formating (black)' - - - script: | - poetry run pre-commit run flake8 --all-files - displayName: 'Code formating (flake8)' - - - script: | - poetry run pre-commit run mypy --all-files - displayName: 'Typing checks (mypy)' - - - script: | - poetry run pre-commit run isort --all-files - displayName: 'Order of imports (isort)' - - - script: | - poetry run pre-commit run trailing-whitespace --all-files - displayName: 'Run trailing-whitespace' - - - script: | - poetry run pre-commit run end-of-file-fixer --all-files - displayName: 'Run end-of-file-fixer' - - - script: | - poetry run pre-commit run check-docstring-first --all-files - displayName: 'Run check-docstring-first' - - - script: | - poetry run pre-commit run check-yaml --all-files - displayName: 'Run check-yaml' - - - script: | - poetry run pre-commit run debug-statements --all-files - displayName: 'Run debug-statements' - - - script: | - poetry run pre-commit run check-ast --all-files - displayName: 'Run check-ast' - - -- stage: "Tests" - jobs: - - job: "Tests" - strategy: - matrix: - Python 3.7 Ubuntu: - python.version: '3.7' - vmImage: 'ubuntu-latest' - - Python 3.8 Ubuntu: - python.version: '3.8' - vmImage: 'ubuntu-latest' - - Python 3.7 Windows: - python.version: '3.7' - vmImage: 'windows-latest' - - Python 3.8 Windows: - python.version: '3.8' - vmImage: 'windows-latest' - - Python 3.7 OSX: - python.version: '3.7' - vmImage: 'macOS-latest' - - Python 3.8 OSX: - python.version: '3.8' - vmImage: 'macOS-latest' - - pool: - vmImage: $(vmImage) - - steps: - - task: UsePythonVersion@0 - inputs: - versionSpec: '$(python.version)' - displayName: 'Use Python $(python.version)' - - - script: | - python -m pip install --upgrade pip poetry - poetry install - displayName: 'Install dependencies' - - - script: | - poetry run pytest --cov kasa --cov-report=xml --cov-report=html - displayName: 'Run tests' - - - script: | - poetry run codecov -t $(codecov.token) - displayName: 'Report code coverage' diff --git a/devtools/README.md b/devtools/README.md new file mode 100644 index 000000000..f59ea374c --- /dev/null +++ b/devtools/README.md @@ -0,0 +1,128 @@ +# Tools for developers + +This directory contains some simple scripts that can be useful for developers. + +## dump_devinfo +* Queries the device (if --host is given) or discover devices and creates fixture files that can be added to the test suite. + +```shell +Usage: python -m devtools.dump_devinfo [OPTIONS] + + Generate devinfo files for devices. + + Use --host (for a single device) or --target (for a complete network). + +Options: + --host TEXT Target host. + --target TEXT Target network for discovery. + --username TEXT Username/email address to authenticate to device. + --password TEXT Password to use to authenticate to device. + --basedir TEXT Base directory for the git repository + --autosave Save without prompting + -d, --debug + --help Show this message and exit. +``` + +## create_module_fixtures + +* Queries the device for all supported modules and outputs module-based fixture files for each device. +* This could be used to create fixture files for module-specific tests, but it might also be useful for other use-cases. + +```shell +Usage: create_module_fixtures.py [OPTIONS] OUTPUTDIR + + Create module fixtures for given host/network. + +Arguments: + OUTPUTDIR [required] + +Options: + --host TEXT + --network TEXT + --install-completion Install completion for the current shell. + --show-completion Show completion for the current shell, to copy it or + customize the installation. + --help Show this message and exit. +``` + +## parse_pcap + +* Requires dpkt (pip install dpkt) +* Reads a pcap file and prints out the device communications + +```shell +Usage: parse_pcap.py [OPTIONS] FILE + + Parse pcap file and pretty print the communications and some statistics. + +Options: + --help Show this message and exit. +``` + +## perftest + +* Runs several rounds of update cycles for the given list of addresses, and prints out statistics about the performance + +```shell +Usage: perf_test.py [OPTIONS] [ADDRS]... + +Options: + --rounds INTEGER + --help Show this message and exit. +``` + +```shell +$ python perf_test.py 192.168.xx.x 192.168.xx.y 192.168.xx.z 192.168.xx.f +Running 5 rounds on ('192.168.xx.x', '192.168.xx.y', '192.168.xx.z', '192.168.xx.f') +=== Testing using gather on all devices === + took + count mean std min 25% 50% 75% max +type +concurrently 5.0 0.097161 0.045544 0.05260 0.055332 0.088811 0.143082 0.145981 +sequential 5.0 0.150506 0.005798 0.14162 0.149065 0.150499 0.155579 0.155768 +=== Testing per-device performance === + took + count mean std min 25% 50% 75% max +id +-HS110(EU) 5.0 0.044917 0.014984 0.035836 0.037728 0.037950 0.041610 0.071458 +-KL130(EU) 5.0 0.067626 0.032027 0.046451 0.046797 0.048406 0.076136 0.120342 +-HS110(EU) 5.0 0.055700 0.016174 0.042086 0.045578 0.048905 0.059869 0.082064 +-KP303(UK) 5.0 0.010298 0.003765 0.007773 0.007968 0.008546 0.010439 0.016763 +``` + +## benchmark + +* Benchmark the protocol + +```shell +% python3 devtools/bench/benchmark.py +New parser, parsing 100000 messages took 0.6339647499989951 seconds +Old parser, parsing 100000 messages took 9.473990250000497 seconds +``` + + +## parse_pcap_klap + +* A tool to allow KLAP data to be exported, in JSON, from a PCAP file of encrypted requests. + +* NOTE: must install pyshark (`pip install pyshark`). +* pyshark requires Wireshark or tshark to be installed on windows and tshark to be installed +on linux (`apt get tshark`) + +```shell +Usage: parse_pcap_klap.py [OPTIONS] + + Export KLAP data in JSON format from a PCAP file. + +Options: + --host TEXT the IP of the smart device as it appears in the pcap + file. [required] + --username TEXT Username/email address to authenticate to device. + [required] + --password TEXT Password to use to authenticate to device. + [required] + --pcap-file-path TEXT The path to the pcap file to parse. [required] + -o, --output TEXT The name of the output file, relative to the current + directory. + --help Show this message and exit. +``` diff --git a/devtools/__init__.py b/devtools/__init__.py new file mode 100644 index 000000000..49189835e --- /dev/null +++ b/devtools/__init__.py @@ -0,0 +1 @@ +"""Devtools package.""" diff --git a/devtools/bench/benchmark.py b/devtools/bench/benchmark.py new file mode 100644 index 000000000..91a3a93dc --- /dev/null +++ b/devtools/bench/benchmark.py @@ -0,0 +1,31 @@ +"""Benchmark the new parser against the old parser.""" + +import json +import timeit + +import orjson +from kasa_crypt import decrypt, encrypt + +from devtools.bench.utils.data import REQUEST, WIRE_RESPONSE +from devtools.bench.utils.original import OriginalTPLinkSmartHomeProtocol + + +def original_request_response() -> None: + """Benchmark the original parser.""" + OriginalTPLinkSmartHomeProtocol.encrypt(json.dumps(REQUEST)) + json.loads(OriginalTPLinkSmartHomeProtocol.decrypt(WIRE_RESPONSE[4:])) + + +def new_request_response() -> None: + """Benchmark the new parser.""" + encrypt(orjson.dumps(REQUEST).decode()) + orjson.loads(decrypt(WIRE_RESPONSE[4:])) + + +count = 100000 + +time = timeit.Timer(new_request_response).timeit(count) +print(f"New parser, parsing {count} messages took {time} seconds") + +time = timeit.Timer(original_request_response).timeit(count) +print(f"Old parser, parsing {count} messages took {time} seconds") diff --git a/devtools/bench/utils/__init__.py b/devtools/bench/utils/__init__.py new file mode 100644 index 000000000..d49281d90 --- /dev/null +++ b/devtools/bench/utils/__init__.py @@ -0,0 +1 @@ +"""Benchmark utils.""" diff --git a/devtools/bench/utils/data.py b/devtools/bench/utils/data.py new file mode 100644 index 000000000..27adc0ea7 --- /dev/null +++ b/devtools/bench/utils/data.py @@ -0,0 +1,140 @@ +"""Test data for benchmarks.""" + +import json + +from .original import OriginalTPLinkSmartHomeProtocol + +REQUEST = { + "system": {"get_sysinfo": None}, + "anti_theft": {"get_rules": None, "get_next_action": None}, + "schedule": { + "get_rules": None, + "get_next_action": None, + "get_realtime": None, + "get_daystat": {"year": 2023, "month": 6}, + "get_monthstat": {"year": 2023}, + }, + "time": {"get_time": None, "get_timezone": None}, + "emeter": { + "get_realtime": None, + "get_daystat": {"year": 2023, "month": 6}, + "get_monthstat": {"year": 2023}, + }, +} +RESPONSE = { + "anti_theft": { + "get_next_action": {"err_code": -2, "err_msg": "member not support"}, + "get_rules": {"enable": 0, "err_code": 0, "rule_list": [], "version": 2}, + }, + "emeter": { + "get_daystat": { + "day_list": [{"day": 30, "energy_wh": 0, "month": 6, "year": 2023}], + "err_code": 0, + }, + "get_monthstat": { + "err_code": 0, + "month_list": [{"energy_wh": 0, "month": 6, "year": 2023}], + }, + "get_realtime": { + "current_ma": 0, + "err_code": 0, + "power_mw": 0, + "slot_id": 0, + "total_wh": 0, + "voltage_mv": 119390, + }, + }, + "schedule": { + "get_daystat": { + "day_list": [{"day": 30, "month": 6, "time": 3, "year": 2023}], + "err_code": 0, + }, + "get_monthstat": { + "err_code": 0, + "month_list": [{"month": 6, "time": 3, "year": 2023}], + }, + "get_next_action": {"err_code": 0, "type": -1}, + "get_realtime": {"err_code": -2, "err_msg": "member not support"}, + "get_rules": {"enable": 1, "err_code": 0, "rule_list": [], "version": 2}, + }, + "system": { + "get_sysinfo": { + "alias": "TP-LINK_Power Strip_5C33", + "child_num": 6, + "children": [ + { + "alias": "Plug 1", + "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031900", + "next_action": {"type": -1}, + "on_time": 231, + "state": 1, + }, + { + "alias": "Plug 2", + "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031901", + "next_action": {"type": -1}, + "on_time": 231, + "state": 1, + }, + { + "alias": "Plug 3", + "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031902", + "next_action": {"type": -1}, + "on_time": 231, + "state": 1, + }, + { + "alias": "Plug 4", + "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031903", + "next_action": {"type": -1}, + "on_time": 231, + "state": 1, + }, + { + "alias": "Plug 5", + "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031904", + "next_action": {"type": -1}, + "on_time": 231, + "state": 1, + }, + { + "alias": "Plug 6", + "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031905", + "next_action": {"type": -1}, + "on_time": 231, + "state": 1, + }, + ], + "deviceId": "8006AF35494E7DB13DDE9B8F40BF2E001E770319", + "err_code": 0, + "feature": "TIM:ENE", + "hwId": "955F433CBA24823A248A59AA64571A73", + "hw_ver": "2.0", + "latitude_i": 297852, + "led_off": 0, + "longitude_i": -954074, + "mac": "C0:06:C3:42:5C:33", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "HS300(US)", + "oemId": "32BD0B21AA9BF8E84737D1DB1C66E883", + "rssi": -41, + "status": "new", + "sw_ver": "1.0.3 Build 201203 Rel.165457", + "updating": 0, + } + }, + "time": { + "get_time": { + "err_code": 0, + "hour": 9, + "mday": 30, + "min": 32, + "month": 6, + "sec": 54, + "year": 2023, + }, + "get_timezone": {"err_code": 0, "index": 13}, + }, +} + +WIRE_RESPONSE = OriginalTPLinkSmartHomeProtocol.encrypt(json.dumps(RESPONSE)) diff --git a/devtools/bench/utils/original.py b/devtools/bench/utils/original.py new file mode 100644 index 000000000..27e9088a8 --- /dev/null +++ b/devtools/bench/utils/original.py @@ -0,0 +1,48 @@ +"""Original implementation of the TP-Link Smart Home protocol.""" + +import struct +from collections.abc import Generator + + +class OriginalTPLinkSmartHomeProtocol: + """Original implementation of the TP-Link Smart Home protocol.""" + + INITIALIZATION_VECTOR = 171 + + @staticmethod + def _xor_payload(unencrypted: bytes) -> Generator[int, None, None]: + key = OriginalTPLinkSmartHomeProtocol.INITIALIZATION_VECTOR + for unencryptedbyte in unencrypted: + key = key ^ unencryptedbyte + yield key + + @staticmethod + def encrypt(request: str) -> bytes: + """Encrypt a request for a TP-Link Smart Home Device. + + :param request: plaintext request data + :return: ciphertext to be send over wire, in bytes + """ + plainbytes = request.encode() + return struct.pack(">I", len(plainbytes)) + bytes( + OriginalTPLinkSmartHomeProtocol._xor_payload(plainbytes) + ) + + @staticmethod + def _xor_encrypted_payload(ciphertext: bytes) -> Generator[int, None, None]: + key = OriginalTPLinkSmartHomeProtocol.INITIALIZATION_VECTOR + for cipherbyte in ciphertext: + plainbyte = key ^ cipherbyte + key = cipherbyte + yield plainbyte + + @staticmethod + def decrypt(ciphertext: bytes) -> str: + """Decrypt a response of a TP-Link Smart Home Device. + + :param ciphertext: encrypted response data + :return: plaintext response + """ + return bytes( + OriginalTPLinkSmartHomeProtocol._xor_encrypted_payload(ciphertext) + ).decode() diff --git a/devtools/create_module_fixtures.py b/devtools/create_module_fixtures.py new file mode 100644 index 000000000..ed881a88b --- /dev/null +++ b/devtools/create_module_fixtures.py @@ -0,0 +1,63 @@ +"""Create fixture files for modules supported by a device. + +This script can be used to create fixture files for individual modules. +""" + +import asyncio +import json +from pathlib import Path +from typing import cast + +import typer + +from kasa import Discover +from kasa.iot import IotDevice + +app = typer.Typer() + + +def create_fixtures(dev: IotDevice, outputdir: Path): + """Iterate over supported modules and create version-specific fixture files.""" + for name, module in dev.modules.items(): + module_dir = outputdir / str(name) + if not module_dir.exists(): + module_dir.mkdir(exist_ok=True, parents=True) + + sw_version = dev.hw_info["sw_ver"] + sw_version = sw_version.split(" ", maxsplit=1)[0] + filename = f"{dev.model}_{dev.hw_info['hw_ver']}_{sw_version}.json" + module_file = module_dir / filename + + if module_file.exists(): + continue + + typer.echo(f"Creating {module_file} for {dev.model}") + with module_file.open("w") as f: + json.dump(module.data, f, indent=4) + + +@app.command() +def create_module_fixtures( + outputdir: Path, + host: str = typer.Option(default=None), + network: str = typer.Option(default=None), +): + """Create module fixtures for given host/network.""" + devs = [] + if host is not None: + dev: IotDevice = cast(IotDevice, asyncio.run(Discover.discover_single(host))) + devs.append(dev) + else: + if network is None: + network = "255.255.255.255" + devs = asyncio.run(Discover.discover(target=network)).values() + for dev in devs: + dev = cast(IotDevice, dev) + asyncio.run(dev.update()) + + for dev in devs: + create_fixtures(dev, outputdir) + + +if __name__ == "__main__": + app() diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py new file mode 100644 index 000000000..027b28bdd --- /dev/null +++ b/devtools/dump_devinfo.py @@ -0,0 +1,1104 @@ +"""This script generates devinfo files for the test suite. + +If you have new, yet unsupported device or a device with no devinfo file under + tests/fixtures, feel free to run this script and create a PR to add the file + to the repository. + +Executing this script will several modules and methods one by one, +and finally execute a query to query all of them at once. +""" + +from __future__ import annotations + +import dataclasses +import json +import logging +import re +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 + +import asyncclick as click + +from devtools.helpers.smartcamrequests import SMARTCAM_REQUESTS +from devtools.helpers.smartrequests import SmartRequest, get_component_requests +from kasa import ( + AuthenticationError, + Credentials, + Device, + DeviceConfig, + DeviceConnectionParameters, + Discover, + KasaException, + TimeoutError, +) +from kasa.device_factory import get_protocol +from kasa.deviceconfig import DeviceEncryptionType, DeviceFamily +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 SmartCamChild, SmartCamDevice +from kasa.smartcam.smartcamchild import CHILD_INFO_FROM_PARENT + +Call = namedtuple("Call", "module method") +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.""" + + module: str + request: dict + should_succeed: bool + child_device_id: str + supports_multiple: bool = True + + +def default_to_regular(d): + """Convert nested defaultdicts to regular ones. + + From https://stackoverflow.com/a/26496899 + """ + if isinstance(d, defaultdict): + d = {k: default_to_regular(v) for k, v in d.items()} + return d + + +async def handle_device( + basedir, autosave, protocol, *, discovery_info=None, batch_size: int +): + """Create a fixture for a single device instance.""" + if isinstance(protocol, SmartProtocol): + fixture_results: list[FixtureResult] = await get_smart_fixtures( + protocol, discovery_info=discovery_info, batch_size=batch_size + ) + else: + fixture_results = [ + await get_legacy_fixture(protocol, discovery_info=discovery_info) + ] + + for fixture_result in fixture_results: + 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(fixture_result.data) + if autosave: + save = "y" + else: + save = click.prompt( + f"Do you want to save the above content to {save_filename} (y/n)" + ) + if save == "y": + click.echo(f"Saving info to {save_filename}") + + with save_filename.open("w") as f: + json.dump(fixture_result.data, f, sort_keys=True, indent=4) + f.write("\n") + else: + click.echo("Not saving.") + + +@click.command() +@click.option("--host", required=False, help="Target host.") +@click.option( + "--target", + required=False, + default="255.255.255.255", + help="Target network for discovery.", +) +@click.option( + "--username", + default="", + required=False, + envvar="KASA_USERNAME", + help="Username/email address to authenticate to device.", +) +@click.option( + "--password", + default="", + required=False, + envvar="KASA_PASSWORD", + help="Password to use to authenticate to device.", +) +@click.option("--basedir", help="Base directory for the git repository", default=".") +@click.option("--autosave", is_flag=True, default=False, help="Save without prompting") +@click.option( + "--batch-size", default=5, help="Number of batched requests to send at once" +) +@click.option("-d", "--debug", is_flag=True) +@click.option( + "-di", + "--discovery-info", + help=( + "Bypass discovery by passing an accurate discovery result json escaped string." + + " Do not use this flag unless you are sure you know what it means." + ), +) +@click.option( + "--discovery-timeout", + envvar="KASA_DISCOVERY_TIMEOUT", + default=10, + required=False, + show_default=True, + help="Timeout for discovery.", +) +@click.option( + "-e", + "--encrypt-type", + envvar="KASA_ENCRYPT_TYPE", + default=None, + type=click.Choice(ENCRYPT_TYPES, case_sensitive=False), +) +@click.option( + "-df", + "--device-family", + envvar="KASA_DEVICE_FAMILY", + default="SMART.TAPOPLUG", + help="Device family type, e.g. `SMART.KASASWITCH`.", +) +@click.option( + "-lv", + "--login-version", + envvar="KASA_LOGIN_VERSION", + default=2, + type=int, + help="The login version for device authentication. Defaults to 2", +) +@click.option( + "--https/--no-https", + envvar="KASA_HTTPS", + default=False, + is_flag=True, + 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, + target, + basedir, + autosave, + debug, + username, + discovery_timeout, + password, + batch_size, + discovery_info, + encrypt_type, + https, + device_family, + login_version, + port, + timeout, +): + """Generate devinfo files for devices. + + Use --host (for a single device) or --target (for a complete network). + """ + 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: + click.echo(f"Host and discovery info given, trying connect on {host}.") + + di = json.loads(discovery_info) + dr = DiscoveryResult.from_dict(di) + connection_type = DeviceConnectionParameters.from_values( + dr.device_type, + dr.mgt_encrypt_schm.encrypt_type, + 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( + basedir, + autosave, + device.protocol, + discovery_info=dr.to_dict(), + batch_size=batch_size, + ) + elif device_family and encrypt_type: + ctype = DeviceConnectionParameters( + DeviceFamily(device_family), + DeviceEncryptionType(encrypt_type), + login_version, + https, + ) + config = DeviceConfig( + host=host, + 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) + else: + raise KasaException( + "Could not find a protocol for the given parameters." + ) + else: + click.echo(f"Host given, performing discovery on {host}.") + device = await Discover.discover_single( + host, + 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=discovery_info, + batch_size=batch_size, + ) + else: + click.echo( + "No --host given, performing discovery on" + f" {target}. Use --target to override." + ) + devices = await Discover.discover( + 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=discovery_info, + batch_size=batch_size, + ) + + +async def get_legacy_fixture( + protocol: IotProtocol, *, discovery_info: dict[str, dict[str, Any]] | None +) -> FixtureResult: + """Get fixture for legacy IOT style protocol.""" + items = [ + Call(module="system", method="get_sysinfo"), + Call(module="emeter", method="get_realtime"), + Call(module="cnCloud", method="get_info"), + Call(module="cnCloud", method="get_intl_fw_list"), + Call(module="smartlife.iot.common.cloud", method="get_info"), + Call(module="smartlife.iot.common.cloud", method="get_intl_fw_list"), + Call(module="smartlife.iot.common.schedule", method="get_next_action"), + Call(module="smartlife.iot.common.schedule", method="get_rules"), + Call(module="schedule", method="get_next_action"), + Call(module="schedule", method="get_rules"), + Call(module="smartlife.iot.dimmer", method="get_dimmer_parameters"), + Call(module="smartlife.iot.dimmer", method="get_default_behavior"), + Call(module="smartlife.iot.common.emeter", method="get_realtime"), + Call( + module="smartlife.iot.smartbulb.lightingservice", method="get_light_state" + ), + Call( + module="smartlife.iot.smartbulb.lightingservice", + method="get_default_behavior", + ), + Call( + module="smartlife.iot.smartbulb.lightingservice", method="get_light_details" + ), + Call(module="smartlife.iot.lightStrip", method="get_default_behavior"), + Call(module="smartlife.iot.lightStrip", method="get_light_state"), + Call(module="smartlife.iot.lightStrip", method="get_light_details"), + Call(module="smartlife.iot.LAS", method="get_config"), + Call(module="smartlife.iot.LAS", method="get_current_brt"), + Call(module="smartlife.iot.LAS", method="get_dark_status"), + 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 = [] + + for test_call in items: + try: + click.echo(f"Testing {test_call}..", nl=False) + info = await protocol.query({test_call.module: {test_call.method: {}}}) + resp = info[test_call.module] + except Exception as ex: + click.echo(click.style(f"FAIL {ex}", fg="red")) + else: + if "err_msg" in resp: + click.echo(click.style(f"FAIL {resp['err_msg']}", fg="red")) + else: + click.echo(click.style("OK", fg="green")) + successes.append((test_call, info)) + finally: + await protocol.close() + + final_query: dict = defaultdict(defaultdict) + final: dict = defaultdict(defaultdict) + for succ, resp in successes: + final_query[succ.module][succ.method] = {} + final[succ.module][succ.method] = resp + + final = default_to_regular(final) + + try: + final = await protocol.query(final_query) + except Exception as ex: + _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"): + 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)) + + sysinfo = final["system"]["get_sysinfo"] + model = sysinfo["model"] + 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}" + copy_folder = IOT_FOLDER + return FixtureResult( + filename=save_filename, + folder=copy_folder, + data=final, + protocol_suffix=IOT_SUFFIX, + ) + + +def _echo_error(msg: str): + click.echo( + click.style( + msg, + bold=True, + fg="red", + ) + ) + + +def format_exception(e): + """Print full exception stack as if it hadn't been caught. + + https://stackoverflow.com/a/12539332 + """ + exception_list = traceback.format_stack() + exception_list = exception_list[:-2] + exception_list.extend(traceback.format_tb(sys.exc_info()[2])) + exception_list.extend( + traceback.format_exception_only(sys.exc_info()[0], sys.exc_info()[1]) + ) + + exception_str = "Traceback (most recent call last):\n" + exception_str += "".join(exception_list) + # Removing the last \n + exception_str = exception_str[:-1] + + return exception_str + + +async def _make_final_calls( + protocol: SmartProtocol, + calls: list[SmartCall], + name: str, + batch_size: int, + *, + child_device_id: str, +) -> dict[str, dict]: + """Call all successes again. + + After trying each call individually make the calls again either as a + multiple request or as single requests for those that don't support + multiple queries. + """ + multiple_requests = { + key: smartcall.request[key] + for smartcall in calls + if smartcall.supports_multiple and (key := next(iter(smartcall.request))) + } + final = await _make_requests_or_exit( + protocol, + multiple_requests, + name + " - multiple", + batch_size, + child_device_id=child_device_id, + ) + single_calls = [smartcall for smartcall in calls if not smartcall.supports_multiple] + for smartcall in single_calls: + final[smartcall.module] = await _make_requests_or_exit( + protocol, + smartcall.request, + f"{name} + {smartcall.module}", + batch_size, + child_device_id=child_device_id, + ) + return final + + +async def _make_requests_or_exit( + protocol: SmartProtocol, + requests: dict, + name: str, + batch_size: int, + *, + child_device_id: str, +) -> dict[str, dict]: + final = {} + # Calling close on child protocol wrappers is a noop + protocol_to_close = protocol + if child_device_id: + if isinstance(protocol, SmartCamProtocol): + protocol = _ChildCameraProtocolWrapper(child_device_id, protocol) + else: + protocol = _ChildProtocolWrapper(child_device_id, protocol) + try: + end = len(requests) + step = batch_size # Break the requests down as there seems to be a size limit + keys = [key for key in requests] + for i in range(0, end, step): + x = i + requests_step = {key: requests[key] for key in keys[x : x + step]} + responses = await protocol.query(requests_step) + for method, result in responses.items(): + final[method] = result + return final + except AuthenticationError as ex: + _echo_error( + f"Unable to query the device due to an authentication error: {ex}", + ) + exit(1) + except KasaException as ex: + _echo_error( + f"Unable to query {name} at once: {ex}", + ) + if isinstance(ex, TimeoutError): + _echo_error( + "Timeout, try reducing the batch size via --batch-size option.", + ) + exit(1) + except Exception as ex: + _echo_error( + f"Unexpected exception querying {name} at once: {ex}", + ) + if _LOGGER.isEnabledFor(logging.DEBUG): + _echo_error(format_exception(ex)) + exit(1) + finally: + await protocol_to_close.close() + + +async def get_smart_camera_test_calls(protocol: SmartProtocol): + """Get the list of test calls to make.""" + test_calls: list[SmartCall] = [] + successes: list[SmartCall] = [] + + test_calls = [] + for request in SMARTCAM_REQUESTS: + method = next(iter(request)) + if method == "get": + module = method + "_" + next(iter(request[method])) + else: + module = method + test_calls.append( + SmartCall( + module=module, + request=request, + should_succeed=True, + child_device_id="", + supports_multiple=(method != "get"), + ) + ) + + # Now get the child device requests + child_request = { + "getChildDeviceList": {"childControl": {"start_index": 0}}, + } + try: + child_response = await protocol.query(child_request) + except Exception: + _LOGGER.debug("Device does not have any children.") + else: + successes.append( + SmartCall( + module="getChildDeviceList", + request=child_request, + should_succeed=True, + child_device_id="", + supports_multiple=True, + ) + ) + child_list = child_response["getChildDeviceList"]["child_device_list"] + for child in child_list: + child_id = child.get("device_id") or child.get("dev_id") + if not child_id: + _LOGGER.error("Could not find child device id in %s", child) + # If category is in the child device map the protocol is smart. + if ( + category := child.get("category") + ) and category in SmartChildDevice.CHILD_DEVICE_TYPE_MAP: + child_protocol = _ChildCameraProtocolWrapper(child_id, protocol) + try: + nego_response = await child_protocol.query({"component_nego": None}) + except Exception as ex: + _LOGGER.error("Error calling component_nego: %s", ex) + continue + if "component_nego" not in nego_response: + _LOGGER.error( + "Could not find component_nego in device response: %s", + nego_response, + ) + continue + successes.append( + SmartCall( + module="component_nego", + request={"component_nego": None}, + should_succeed=True, + child_device_id=child_id, + ) + ) + child_components = { + item["id"]: item["ver_code"] + for item in nego_response["component_nego"]["component_list"] + } + for component_id, ver_code in child_components.items(): + if ( + requests := get_component_requests(component_id, ver_code) + ) is not None: + component_test_calls = [ + SmartCall( + module=component_id, + request={key: val}, + should_succeed=True, + child_device_id=child_id, + ) + for key, val in requests.items() + ] + test_calls.extend(component_test_calls) + else: + click.echo(f"Skipping {component_id}..", nl=False) + click.echo(click.style("UNSUPPORTED", fg="yellow")) + else: # Not a smart protocol device so assume camera protocol + for request in SMARTCAM_REQUESTS: + method = next(iter(request)) + if method == "get": + method = method + "_" + next(iter(request[method])) + test_calls.append( + SmartCall( + module=method, + request=request, + should_succeed=True, + child_device_id=child_id, + ) + ) + finally: + await protocol.close() + return test_calls, successes + + +async def get_smart_test_calls(protocol: SmartProtocol): + """Get the list of test calls to make.""" + test_calls = [] + successes = [] + child_device_components = {} + + click.echo("Testing component_nego call ..", nl=False) + responses = await _make_requests_or_exit( + protocol, + SmartRequest.component_nego().to_dict(), + "component_nego call", + batch_size=1, + child_device_id="", + ) + component_info_response = responses["component_nego"] + click.echo(click.style("OK", fg="green")) + successes.append( + SmartCall( + module="component_nego", + request=SmartRequest("component_nego").to_dict(), + should_succeed=True, + child_device_id="", + ) + ) + components = { + item["id"]: item["ver_code"] + for item in component_info_response["component_list"] + } + + if "child_device" in components: + child_components = await _make_requests_or_exit( + protocol, + SmartRequest.get_child_device_component_list().to_dict(), + "child device component list", + batch_size=1, + child_device_id="", + ) + successes.append( + SmartCall( + module="child_component_list", + request=SmartRequest.get_child_device_component_list().to_dict(), + should_succeed=True, + child_device_id="", + ) + ) + test_calls.append( + SmartCall( + module="child_device_list", + request=SmartRequest.get_child_device_list().to_dict(), + should_succeed=True, + child_device_id="", + ) + ) + # Get list of child components to call + if "control_child" in components: + child_device_components = { + child_component_list["device_id"]: { + item["id"]: item["ver_code"] + for item in child_component_list["component_list"] + } + for child_component_list in child_components[ + "get_child_device_component_list" + ]["child_component_list"] + } + + # Get component calls + for component_id, ver_code in components.items(): + if component_id == "child_device": + continue + if (requests := get_component_requests(component_id, ver_code)) is not None: + component_test_calls = [ + SmartCall( + module=component_id, + request={key: val}, + should_succeed=True, + child_device_id="", + ) + for key, val in requests.items() + ] + test_calls.extend(component_test_calls) + else: + click.echo(f"Skipping {component_id}..", nl=False) + click.echo(click.style("UNSUPPORTED", fg="yellow")) + + # Child component calls + for child_device_id, child_components in child_device_components.items(): + test_calls.append( + SmartCall( + module="component_nego", + request=SmartRequest("component_nego").to_dict(), + should_succeed=True, + child_device_id=child_device_id, + ) + ) + for component_id, ver_code in child_components.items(): + if (requests := get_component_requests(component_id, ver_code)) is not None: + component_test_calls = [ + SmartCall( + module=component_id, + request={key: val}, + should_succeed=True, + child_device_id=child_device_id, + ) + for key, val in requests.items() + ] + test_calls.extend(component_test_calls) + else: + click.echo(f"Skipping {component_id}..", nl=False) + click.echo(click.style("UNSUPPORTED", fg="yellow")) + + return test_calls, successes + + +def get_smart_child_fixture(response, model_info, folder, suffix): + """Get a seperate fixture for the child device.""" + 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}" + return FixtureResult( + 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, dict[str, Any]] | None, + batch_size: int, +) -> list[FixtureResult]: + """Get fixture for new TAPO style protocol.""" + if isinstance(protocol, SmartCamProtocol): + test_calls, successes = await get_smart_camera_test_calls(protocol) + child_wrapper: type[_ChildProtocolWrapper | _ChildCameraProtocolWrapper] = ( + _ChildCameraProtocolWrapper + ) + else: + test_calls, successes = await get_smart_test_calls(protocol) + child_wrapper = _ChildProtocolWrapper + + for test_call in test_calls: + click.echo(f"Testing {test_call.module}..", nl=False) + try: + click.echo(f"Testing {test_call}..", nl=False) + if test_call.child_device_id == "": + response = await protocol.query(test_call.request) + else: + cp = child_wrapper(test_call.child_device_id, protocol) + response = await cp.query(test_call.request) + except AuthenticationError as ex: + _echo_error( + f"Unable to query the device due to an authentication error: {ex}", + ) + exit(1) + except Exception as ex: + if ( + not test_call.should_succeed + and hasattr(ex, "error_code") + and ex.error_code + in [ + SmartErrorCode.UNKNOWN_METHOD_ERROR, + SmartErrorCode.TRANSPORT_NOT_AVAILABLE_ERROR, + SmartErrorCode.UNSPECIFIC_ERROR, + ] + ): + click.echo(click.style("FAIL - EXPECTED", fg="green")) + else: + click.echo(click.style(f"FAIL {ex}", fg="red")) + else: + if not response: + click.echo(click.style("FAIL no response", fg="red")) + else: + if not test_call.should_succeed: + click.echo(click.style("OK - EXPECTED FAIL", fg="red")) + else: + click.echo(click.style("OK", fg="green")) + successes.append(test_call) + 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) + + 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 + response = await _make_final_calls( + protocol, + requests, + "All child successes", + batch_size, + child_device_id=child_device_id, + ) + 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: + parent_model = None + _LOGGER.error("Cannot determine parent device model.") + + # different model smart child device + if ( + (child_model := response.get("get_device_info", {}).get("model")) + and parent_model + and child_model != parent_model + ): + 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_child_id] = response + + discovery_result = None + if discovery_info: + 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_result) + copy_folder = SMART_FOLDER + protocol_suffix = SMART_PROTOCOL_SUFFIX + else: + # smart camera protocol + 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}" + + fixture_results.insert( + 0, + FixtureResult( + filename=save_filename, + folder=copy_folder, + data=final, + protocol_suffix=protocol_suffix, + ), + ) + return fixture_results + + +if __name__ == "__main__": + cli() diff --git a/devtools/generate_supported.py b/devtools/generate_supported.py new file mode 100755 index 000000000..669a2de2e --- /dev/null +++ b/devtools/generate_supported.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python +"""Script that checks supported devices and updates README.md and SUPPORTED.md.""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path +from string import Template +from typing import Any, NamedTuple + +from kasa.device_type import DeviceType +from kasa.iot import IotDevice +from kasa.smart import SmartDevice +from kasa.smartcam import SmartCamChild, SmartCamDevice + + +class SupportedVersion(NamedTuple): + """Supported version.""" + + region: str | None + hw: str + fw: str + auth: bool + + +# The order of devices in this dict drives the display order +DEVICE_TYPE_TO_PRODUCT_GROUP = { + DeviceType.Plug: "Plugs", + DeviceType.Strip: "Power Strips", + DeviceType.StripSocket: "Power Strips", + DeviceType.Dimmer: "Wall Switches", + DeviceType.WallSwitch: "Wall Switches", + DeviceType.Fan: "Wall Switches", + 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", +} + + +SUPPORTED_FILENAME = "SUPPORTED.md" +README_FILENAME = "README.md" + +IOT_FOLDER = "tests/fixtures/iot/" +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): + """Generate the SUPPORTED.md from the fixtures.""" + print_diffs = "--print-diffs" in args + running_in_ci = "CI" in os.environ + print("Generating supported devices") + if running_in_ci: + print_diffs = True + print("Detected running in CI") + + supported = {"kasa": {}, "tapo": {}} + + _get_supported_devices(supported, IOT_FOLDER, IotDevice) + _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 + ) + supported_updated = _update_supported_file( + SUPPORTED_FILENAME, _supported_detail(supported), print_diffs + ) + if not readme_updated and not supported_updated: + print("Supported devices unchanged.") + + +def _update_supported_file(filename, supported_text, print_diffs) -> bool: + with open(filename) as f: + contents = f.readlines() + + start_index = end_index = None + for index, line in enumerate(contents): + if line == "\n": + start_index = index + 1 + if line == "\n": + end_index = index + + current_text = "".join(contents[start_index:end_index]) + if current_text != supported_text: + print( + f"{filename} has been modified with updated " + + "supported devices, add file to commit." + ) + if print_diffs: + print("##CURRENT##") + print(current_text) + print("##NEW##") + print(supported_text) + + new_contents = contents[:start_index] + end_contents = contents[end_index:] + new_contents.append(supported_text) + new_contents.extend(end_contents) + + with open(filename, "w") as f: + new_contents_text = "".join(new_contents) + f.write(new_contents_text) + return True + return False + + +def _supported_summary(supported): + return _supported_text( + supported, + "### Supported $brand$auth devices\n\n$types\n", + "- **$type_$type_asterix**: $models\n", + ) + + +def _supported_detail(supported): + return _supported_text( + supported, + "## $brand devices\n\n$preamble\n\n$types\n", + "### $type_\n\n$models\n", + "- **$model**\n$versions", + " - Hardware: $hw$region / Firmware: $fw$auth_flag\n", + ) + + +def _supported_text( + supported, brand_template, types_template, model_template="", version_template="" +): + brandt = Template(brand_template) + typest = Template(types_template) + modelt = Template(model_template) + versst = Template(version_template) + brands = "" + version: SupportedVersion + for brand, types in supported.items(): + preamble_text = ( + "Some newer Kasa devices require authentication. " + + "These are marked with [^1] in the list below." + if brand == "kasa" + else "All Tapo devices require authentication." + ) + preamble_text += ( + "
Hub-Connected Devices may work across TAPO/KASA branded " + + "hubs even if they don't work across the native apps." + ) + brand_text = brand.capitalize() + brand_auth = r"[^1]" if brand == "tapo" else "" + types_text = "" + for supported_type, models in sorted( + # Sort by device type order in the enum + types.items(), + key=lambda st: list(DEVICE_TYPE_TO_PRODUCT_GROUP.values()).index(st[0]), + ): + models_list = [] + models_text = "" + for model, versions in sorted(models.items()): + auth_count = 0 + versions_text = "" + for version in sorted(versions): + region_text = f" ({version.region})" if version.region else "" + auth_count += 1 if version.auth else 0 + vauth_flag = r"[^1]" if version.auth and brand == "kasa" else "" + if version_template: + versions_text += versst.substitute( + hw=version.hw, + fw=version.fw, + region=region_text, + auth_flag=vauth_flag, + ) + if brand == "kasa" and auth_count > 0: + auth_flag = r"[^1]" if auth_count == len(versions) else r"[^2]" + else: + auth_flag = "" + if model_template: + models_text += modelt.substitute( + model=model, versions=versions_text, auth_flag=auth_flag + ) + else: + models_list.append(f"{model}{auth_flag}") + models_text = models_text if models_text else ", ".join(models_list) + type_asterix = r"[^3]" if supported_type == "Hub-Connected Devices" else "" + types_text += typest.substitute( + type_=supported_type, type_asterix=type_asterix, models=models_text + ) + brands += brandt.substitute( + brand=brand_text, types=types_text, auth=brand_auth, preamble=preamble_text + ) + return brands + + +def _get_supported_devices( + supported: dict[str, Any], + fixture_location: str, + device_cls: type[IotDevice | SmartDevice | SmartCamDevice], +): + for file in Path(fixture_location).glob("*.json"): + with file.open() as f: + fixture_data = json.load(f) + + model_info = device_cls._get_device_info( + fixture_data, fixture_data.get("discovery_result", {}).get("result") + ) + + supported_type = DEVICE_TYPE_TO_PRODUCT_GROUP[model_info.device_type] + + stype = supported[model_info.brand].setdefault(supported_type, {}) + smodel = stype.setdefault(model_info.long_name, []) + smodel.append( + SupportedVersion( + region=model_info.region if model_info.region else "", + hw=model_info.hardware_version, + fw=model_info.firmware_version, + auth=model_info.requires_auth, + ) + ) + + +def main(): + """Entry point to module.""" + generate_supported(sys.argv[1:]) + + +if __name__ == "__main__": + generate_supported(sys.argv[1:]) diff --git a/devtools/helpers/__init__.py b/devtools/helpers/__init__.py new file mode 100644 index 000000000..182958c66 --- /dev/null +++ b/devtools/helpers/__init__.py @@ -0,0 +1 @@ +"""Helpers package.""" diff --git a/devtools/helpers/smartcamrequests.py b/devtools/helpers/smartcamrequests.py new file mode 100644 index 000000000..6c60b12a7 --- /dev/null +++ b/devtools/helpers/smartcamrequests.py @@ -0,0 +1,71 @@ +"""Module for smart camera requests.""" + +from __future__ import annotations + +SMARTCAM_REQUESTS: list[dict] = [ + {"getAlertTypeList": {"msg_alarm": {"name": "alert_type"}}}, + {"getNightVisionCapability": {"image_capability": {"name": ["supplement_lamp"]}}}, + {"getDeviceInfo": {"device_info": {"name": ["basic_info"]}}}, + {"getDetectionConfig": {"motion_detection": {"name": ["motion_det"]}}}, + {"getPersonDetectionConfig": {"people_detection": {"name": ["detection"]}}}, + {"getVehicleDetectionConfig": {"vehicle_detection": {"name": ["detection"]}}}, + {"getBCDConfig": {"sound_detection": {"name": ["bcd"]}}}, + {"getPetDetectionConfig": {"pet_detection": {"name": ["detection"]}}}, + {"getBarkDetectionConfig": {"bark_detection": {"name": ["detection"]}}}, + {"getMeowDetectionConfig": {"meow_detection": {"name": ["detection"]}}}, + {"getGlassDetectionConfig": {"glass_detection": {"name": ["detection"]}}}, + {"getTamperDetectionConfig": {"tamper_detection": {"name": "tamper_det"}}}, + {"getLensMaskConfig": {"lens_mask": {"name": ["lens_mask_info"]}}}, + {"getLdc": {"image": {"name": ["switch", "common"]}}}, + {"getLastAlarmInfo": {"system": {"name": ["last_alarm_info"]}}}, + {"getLedStatus": {"led": {"name": ["config"]}}}, + {"getTargetTrackConfig": {"target_track": {"name": ["target_track_info"]}}}, + {"getPresetConfig": {"preset": {"name": ["preset"]}}}, + {"getFirmwareUpdateStatus": {"cloud_config": {"name": "upgrade_status"}}}, + {"getMediaEncrypt": {"cet": {"name": ["media_encrypt"]}}}, + {"getConnectionType": {"network": {"get_connection_type": []}}}, + { + "getAlertConfig": { + "msg_alarm": { + "name": ["chn1_msg_alarm_info", "capability"], + "table": ["usr_def_audio"], + } + } + }, + {"getAlertPlan": {"msg_alarm_plan": {"name": "chn1_msg_alarm_plan"}}}, + {"getSirenTypeList": {"siren": {}}}, + {"getSirenConfig": {"siren": {}}}, + {"getLightTypeList": {"msg_alarm": {}}}, + {"getSirenStatus": {"siren": {}}}, + {"getLightFrequencyInfo": {"image": {"name": "common"}}}, + {"getRotationStatus": {"image": {"name": ["switch"]}}}, + {"getNightVisionModeConfig": {"image": {"name": "switch"}}}, + {"getWhitelampStatus": {"image": {"get_wtl_status": ["null"]}}}, + {"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"]}}}, + {"getFirmwareAutoUpgradeConfig": {"auto_upgrade": {"name": ["common"]}}}, + {"getVideoQualities": {"video": {"name": ["main"]}}}, + {"getVideoCapability": {"video_capability": {"name": "main"}}}, + {"getTimezone": {"system": {"name": "basic"}}}, + {"getClockStatus": {"system": {"name": "clock_status"}}}, + {"getAppComponentList": {"app_component": {"name": "app_component_list"}}}, + {"getChildDeviceComponentList": {"childControl": {"start_index": 0}}}, + # single request only methods + {"get": {"function": {"name": ["module_spec"]}}}, + {"get": {"cet": {"name": ["vhttpd"]}}}, + {"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 new file mode 100644 index 000000000..1ff379160 --- /dev/null +++ b/devtools/helpers/smartrequests.py @@ -0,0 +1,472 @@ +"""SmartRequest helper classes and functions for new SMART/TAPO devices. + +List of known requests with associated parameter classes. + +Other requests that are known but not currently implemented +or tested are: + +get_child_device_component_list +get_child_device_list +control_child +get_device_running_info - seems to be a subset of get_device_info + +get_tss_info +get_raw_dvi +get_homekit_info + +fw_download + +sync_env +account_sync + +device_reset +close_device_ble +heart_beat + +""" + +from __future__ import annotations + +import logging +from dataclasses import asdict, dataclass + +_LOGGER = logging.getLogger(__name__) + + +class SmartRequest: + """Class to represent a smart protocol request.""" + + def __init__(self, method_name: str, params: SmartRequestParams | None = None): + self.method_name = method_name + if params: + self.params = params.to_dict() + else: + self.params = None + + def __repr__(self): + return f"SmartRequest({self.method_name})" + + def to_dict(self): + """Return the request as a dict suitable for passing to query().""" + return {self.method_name: self.params} + + @dataclass + class SmartRequestParams: + """Base class for Smart request params. + + The to_dict() method of this class omits null values which + is required by the devices. + """ + + def to_dict(self): + """Return the params as a dict with values of None ommited.""" + return asdict( + self, dict_factory=lambda x: {k: v for (k, v) in x if v is not None} + ) + + @dataclass + class DeviceOnParams(SmartRequestParams): + """Get Rules Params.""" + + device_on: bool + + @dataclass + class GetRulesParams(SmartRequestParams): + """Get Rules Params.""" + + start_index: int = 0 + + @dataclass + class GetScheduleRulesParams(SmartRequestParams): + """Get Rules Params.""" + + start_index: int = 0 + schedule_mode: str = "" + + @dataclass + class GetTriggerLogsParams(SmartRequestParams): + """Trigger Logs params.""" + + page_size: int = 5 + start_id: int = 0 + + @dataclass + class LedStatusParams(SmartRequestParams): + """LED Status params.""" + + led_rule: str | None = None + + @staticmethod + def from_bool(state: bool): + """Set the led_rule from the state.""" + rule = "always" if state else "never" + return SmartRequest.LedStatusParams(led_rule=rule) + + @dataclass + class LightInfoParams(SmartRequestParams): + """LightInfo params.""" + + brightness: int | None = None + color_temp: int | None = None + hue: int | None = None + saturation: int | None = None + + @dataclass + class DynamicLightEffectParams(SmartRequestParams): + """LightInfo params.""" + + 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 + ) -> SmartRequest: + """Send a raw request to the device.""" + return SmartRequest(method, params) + + @staticmethod + def component_nego() -> SmartRequest: + """Get quick setup component info.""" + return SmartRequest("component_nego") + + @staticmethod + def get_device_info() -> SmartRequest: + """Get device info.""" + return SmartRequest("get_device_info") + + @staticmethod + def get_device_usage() -> SmartRequest: + """Get device usage.""" + return SmartRequest("get_device_usage") + + @staticmethod + def device_info_list(ver_code) -> list[SmartRequest]: + """Get device info list.""" + if ver_code == 1: + return [SmartRequest.get_device_info()] + return [ + SmartRequest.get_device_info(), + SmartRequest.get_device_usage(), + SmartRequest.get_auto_update_info(), + ] + + @staticmethod + def get_auto_update_info() -> SmartRequest: + """Get auto update info.""" + return SmartRequest("get_auto_update_info") + + @staticmethod + def firmware_info_list() -> list[SmartRequest]: + """Get info list.""" + return [ + SmartRequest.get_raw_request("get_fw_download_state"), + SmartRequest.get_raw_request("get_latest_fw"), + ] + + @staticmethod + def qs_component_nego() -> SmartRequest: + """Get quick setup component info.""" + return SmartRequest("qs_component_nego") + + @staticmethod + def get_device_time() -> SmartRequest: + """Get device time.""" + return SmartRequest("get_device_time") + + @staticmethod + def get_child_device_list() -> SmartRequest: + """Get child device list.""" + return SmartRequest("get_child_device_list") + + @staticmethod + def get_child_device_component_list() -> SmartRequest: + """Get child device component list.""" + return SmartRequest("get_child_device_component_list") + + @staticmethod + def get_wireless_scan_info( + params: GetRulesParams | None = None, + ) -> SmartRequest: + """Get wireless scan info.""" + return SmartRequest( + "get_wireless_scan_info", params or SmartRequest.GetRulesParams() + ) + + @staticmethod + def get_schedule_rules(params: GetRulesParams | None = None) -> SmartRequest: + """Get schedule rules.""" + return SmartRequest( + "get_schedule_rules", params or SmartRequest.GetScheduleRulesParams() + ) + + @staticmethod + def get_next_event(params: GetRulesParams | None = None) -> SmartRequest: + """Get next scheduled event.""" + return SmartRequest("get_next_event", params or SmartRequest.GetRulesParams()) + + @staticmethod + def schedule_info_list() -> list[SmartRequest]: + """Get schedule info list.""" + return [ + SmartRequest.get_schedule_rules(), + SmartRequest.get_next_event(), + ] + + @staticmethod + def get_countdown_rules(params: GetRulesParams | None = None) -> SmartRequest: + """Get countdown rules.""" + return SmartRequest( + "get_countdown_rules", params or SmartRequest.GetRulesParams() + ) + + @staticmethod + def get_antitheft_rules(params: GetRulesParams | None = None) -> SmartRequest: + """Get antitheft rules.""" + return SmartRequest( + "get_antitheft_rules", params or SmartRequest.GetRulesParams() + ) + + @staticmethod + def get_led_info(params: LedStatusParams | None = None) -> SmartRequest: + """Get led info.""" + return SmartRequest("get_led_info", params or SmartRequest.LedStatusParams()) + + @staticmethod + def get_auto_off_config(params: GetRulesParams | None = None) -> SmartRequest: + """Get auto off config.""" + return SmartRequest( + "get_auto_off_config", params or SmartRequest.GetRulesParams() + ) + + @staticmethod + def get_delay_action_info() -> SmartRequest: + """Get delay action info.""" + return SmartRequest("get_delay_action_info") + + @staticmethod + def auto_off_list() -> list[SmartRequest]: + """Get energy usage.""" + return [ + SmartRequest.get_auto_off_config(), + SmartRequest.get_delay_action_info(), # May not live here + ] + + @staticmethod + def get_energy_usage() -> SmartRequest: + """Get energy usage.""" + return SmartRequest("get_energy_usage") + + @staticmethod + def energy_monitoring_list() -> list[SmartRequest]: + """Get energy usage.""" + return [ + SmartRequest("get_energy_usage"), + SmartRequest("get_emeter_data"), + SmartRequest("get_emeter_vgain_igain"), + SmartRequest.get_raw_request("get_electricity_price_config"), + ] + + @staticmethod + def get_current_power() -> SmartRequest: + """Get current power.""" + return SmartRequest("get_current_power") + + @staticmethod + def power_protection_list() -> list[SmartRequest]: + """Get power protection info list.""" + return [ + SmartRequest.get_current_power(), + SmartRequest.get_raw_request("get_max_power"), + SmartRequest.get_raw_request("get_protection_power"), + ] + + @staticmethod + def get_preset_rules(params: GetRulesParams | None = None) -> SmartRequest: + """Get preset rules.""" + return SmartRequest("get_preset_rules", params or SmartRequest.GetRulesParams()) + + @staticmethod + def get_on_off_gradually_info( + params: SmartRequestParams | None = None, + ) -> SmartRequest: + """Get preset rules.""" + return SmartRequest( + "get_on_off_gradually_info", params or SmartRequest.SmartRequestParams() + ) + + @staticmethod + def get_auto_light_info() -> SmartRequest: + """Get auto light info.""" + return SmartRequest("get_auto_light_info") + + @staticmethod + def get_dynamic_light_effect_rules( + params: GetRulesParams | None = None, + ) -> SmartRequest: + """Get dynamic light effect rules.""" + return SmartRequest( + "get_dynamic_light_effect_rules", params or SmartRequest.GetRulesParams() + ) + + @staticmethod + def set_device_on(params: DeviceOnParams) -> SmartRequest: + """Set device on state.""" + return SmartRequest("set_device_info", params) + + @staticmethod + def set_light_info(params: LightInfoParams) -> SmartRequest: + """Set color temperature.""" + return SmartRequest("set_device_info", params) + + @staticmethod + def set_dynamic_light_effect_rule_enable( + params: DynamicLightEffectParams, + ) -> SmartRequest: + """Enable dynamic light effect rule.""" + return SmartRequest("set_dynamic_light_effect_rule_enable", params) + + @staticmethod + def get_component_info_requests(component_nego_response) -> list[SmartRequest]: + """Get a list of requests based on the component info response.""" + request_list: list[SmartRequest] = [] + for component in component_nego_response["component_list"]: + if ( + requests := get_component_requests( + component["id"], int(component["ver_code"]) + ) + ) is not None: + request_list.extend(requests) + return request_list + + @staticmethod + def _create_request_dict( + smart_request: SmartRequest | list[SmartRequest], + ) -> dict: + """Create request dict to be passed to SmartProtocol.query().""" + if isinstance(smart_request, list): + request = {} + for sr in smart_request: + request[sr.method_name] = sr.params + else: + request = smart_request.to_dict() + return request + + +def get_component_requests(component_id, ver_code): + """Get the requests supported by the component and version.""" + if (cr := COMPONENT_REQUESTS.get(component_id)) is None: + return None + if callable(cr): + return SmartRequest._create_request_dict(cr(ver_code)) + return SmartRequest._create_request_dict(cr) + + +COMPONENT_REQUESTS = { + "device": SmartRequest.device_info_list, + "firmware": SmartRequest.firmware_info_list(), + "quick_setup": [SmartRequest.qs_component_nego()], + "inherit": [SmartRequest.get_raw_request("get_inherit_info")], + "time": [SmartRequest.get_device_time()], + "wireless": [SmartRequest.get_wireless_scan_info()], + "schedule": SmartRequest.schedule_info_list(), + "countdown": [SmartRequest.get_countdown_rules()], + "antitheft": [SmartRequest.get_antitheft_rules()], + "account": [], + "synchronize": [], # sync_env + "sunrise_sunset": [], # for schedules + "led": [SmartRequest.get_led_info()], + "cloud_connect": [SmartRequest.get_raw_request("get_connect_cloud_state")], + "iot_cloud": [], + "device_local_time": [], + "default_states": [], # in device_info + "auto_off": [SmartRequest.get_auto_off_config()], + "localSmart": [], + "energy_monitoring": SmartRequest.energy_monitoring_list(), + "power_protection": SmartRequest.power_protection_list(), + "current_protection": [], # overcurrent in device_info + "matter": [SmartRequest.get_raw_request("get_matter_setup_info")], + "preset": [SmartRequest.get_preset_rules()], + "brightness": [], # in device_info + "color": [], # in device_info + "color_temperature": [], # in device_info + "auto_light": [SmartRequest.get_auto_light_info()], + "light_effect": [SmartRequest.get_dynamic_light_effect_rules()], + "bulb_quick_control": [], + "on_off_gradually": [SmartRequest.get_on_off_gradually_info()], + "light_strip": [], + "light_strip_lighting_effect": [ + SmartRequest.get_raw_request("get_lighting_effect") + ], + "music_rhythm": [], # music_rhythm_enable in device_info + "segment": [SmartRequest.get_raw_request("get_device_segment")], + "segment_effect": [SmartRequest.get_raw_request("get_segment_effect_rule")], + "device_load": [SmartRequest.get_raw_request("get_device_load_info")], + "child_quick_setup": [ + SmartRequest.get_raw_request("get_support_child_device_category") + ], + "alarm": [ + SmartRequest.get_raw_request("get_support_alarm_type_list"), + SmartRequest.get_raw_request("get_alarm_configure"), + ], + "alarm_logs": [SmartRequest.get_raw_request("get_alarm_triggers")], + "trigger_log": [ + SmartRequest.get_raw_request( + "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"), + SmartRequest.get_raw_request("get_child_device_component_list"), + ], + "control_child": [], + "homekit": [SmartRequest.get_raw_request("get_homekit_info")], + "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.py b/devtools/parse_pcap.py index f9a55c88d..f21897552 100644 --- a/devtools/parse_pcap.py +++ b/devtools/parse_pcap.py @@ -3,18 +3,19 @@ import json from collections import Counter, defaultdict from pprint import pformat as pf -from pprint import pprint as pp import click import dpkt from dpkt.ethernet import ETH_TYPE_IP, Ethernet -from kasa.protocol import TPLinkSmartHomeProtocol + +from kasa.cli.main import echo +from kasa.transports.xortransport import XorEncryption def read_payloads_from_file(file): """Read the given pcap file and yield json payloads.""" pcap = dpkt.pcap.Reader(file) - for ts, pkt in pcap: + for _ts, pkt in pcap: eth = Ethernet(pkt) if eth.type != ETH_TYPE_IP: continue @@ -33,23 +34,23 @@ def read_payloads_from_file(file): data = transport.data try: - decrypted = TPLinkSmartHomeProtocol.decrypt(data[4:]) + decrypted = XorEncryption.decrypt(data[4:]) except Exception as ex: - click.echo( - click.style(f"Unable to decrypt the data, ignoring: {ex}", fg="red") - ) + echo(f"[red]Unable to decrypt the data, ignoring: {ex}[/red]") + continue + + if not decrypted: # skip empty payloads continue try: json_payload = json.loads(decrypted) except Exception as ex: - click.echo( - click.style(f"Unable to parse payload, ignoring: {ex}", fg="red") - ) + # this can happen when the response is split into multiple tcp segments + echo(f"[red]Unable to parse payload '{decrypted}', ignoring: {ex}[/red]") continue if not json_payload: # ignore empty payloads - click.echo(click.style("Got empty payload, ignoring", fg="red")) + echo("[red]Got empty payload, ignoring[/red]") continue yield json_payload @@ -66,7 +67,7 @@ def parse_pcap(file): for module, cmds in json_payload.items(): seen_items["modules"][module] += 1 if "err_code" in cmds: - click.echo(click.style("Got error for module: %s" % cmds, fg="red")) + echo(f"[red]Got error for module: {cmds}[/red]") continue for cmd, response in cmds.items(): @@ -75,30 +76,25 @@ def parse_pcap(file): if response is None: continue direction = ">>" - style = {} if response is None: - print("got none as response for %s, weird?" % (cmd)) + echo(f"got none as response for {cmd} %s, weird?") continue + is_success = "[green]+[/green]" if "err_code" in response: direction = "<<" if response["err_code"] != 0: seen_items["errorcodes"][response["err_code"]] += 1 seen_items["errors"][response["err_msg"]] += 1 - print(response) - style = {"bold": True, "fg": "red"} - else: - style = {"fg": "green"} + is_success = "[red]![/red]" context_str = f" [ctx: {context}]" if context else "" - click.echo( - click.style( - f"{direction}{context_str} {module}.{cmd}: {pf(response)}", - **style, - ) + echo( + f"[{is_success}] {direction}{context_str} {module}.{cmd}:" + f" {pf(response)}" ) - pp(seen_items) + echo(pf(seen_items)) if __name__ == "__main__": diff --git a/devtools/parse_pcap_klap.py b/devtools/parse_pcap_klap.py new file mode 100755 index 000000000..848e33dc6 --- /dev/null +++ b/devtools/parse_pcap_klap.py @@ -0,0 +1,370 @@ +#!/usr/bin/env python +""" +This code allow for the decryption of KlapV2 data from a pcap file. + +It will output the decrypted data to a file. +This was designed and tested with a Tapo light strip setup using a cloud account. +""" + +from __future__ import annotations + +import asyncio +import codecs +import json +import re +from threading import Thread + +import asyncclick as click +import pyshark +from cryptography.hazmat.primitives import padding + +from kasa.credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials +from kasa.deviceconfig import ( + DeviceConfig, + DeviceConnectionParameters, + DeviceEncryptionType, + DeviceFamily, +) +from kasa.transports.klaptransport import KlapEncryptionSession, KlapTransportV2 + + +def _get_seq_from_query(packet): + """Return sequence number for the query.""" + query = packet.http.get("request_uri_query") + if query is None: + raise Exception("No request_uri_query found") + # use regex to get: seq=(\d+) + seq = re.search(r"seq=(\d+)", query) + if seq is not None: + return int(seq.group(1)) + raise Exception("Unable to find sequence number") + + +def _is_http_response_for_packet(response, packet): + """Return True if the *response* contains a response for request in *packet*. + + Different tshark versions use different field for the information. + """ + if not hasattr(response, "http"): + return False + if hasattr(response.http, "response_for_uri") and ( + response.http.response_for_uri == packet.http.request_full_uri + ): + return True + # tshark 4.4.0 + return response.http.request_uri == packet.http.request_uri + + +class MyEncryptionSession(KlapEncryptionSession): + """A custom KlapEncryptionSession class that allows for decryption.""" + + def decrypt(self, msg): + """Decrypt the data.""" + decryptor = self._cipher.decryptor() + dp = decryptor.update(msg[32:]) + decryptor.finalize() + unpadder = padding.PKCS7(128).unpadder() + plaintextbytes = unpadder.update(dp) + unpadder.finalize() + + return plaintextbytes.decode("utf-8", "bad_chars_replacement") + + +class Operator: + """A class that handles the data decryption, and the encryption session updating.""" + + def __init__(self, klap, creds): + self._local_seed: bytes | None = None + self._remote_seed: bytes | None = None + self._session: MyEncryptionSession | None = None + self._creds = creds + self._klap: KlapTransportV2 = klap + self._auth_hash = self._klap.generate_auth_hash(self._creds) + self._local_seed_auth_hash = None + self._remote_seed_auth_hash = None + self._seq = 0 + + def check_default_credentials(self): + """Check whether default credentials were used. + + Devices sometimes randomly accept the hardcoded default credentials + and the library handles that. + """ + for value in DEFAULT_CREDENTIALS.values(): + default_credentials = get_default_credentials(value) + default_auth_hash = self._klap.generate_auth_hash(default_credentials) + default_credentials_seed_auth_hash = self._klap.handshake1_seed_auth_hash( + self._local_seed, + self._remote_seed, + default_auth_hash, # type: ignore + ) + if self._remote_seed_auth_hash == default_credentials_seed_auth_hash: + return default_auth_hash + return None + + def update_encryption_session(self): + """Update the encryption session used for decrypting data. + + It is called whenever the local_seed, remote_seed, + or remote_auth_hash is updated. + + It checks if the seeds are set and, if they are, creates a new session. + + Raises: + ValueError: If the auth hashes do not match. + """ + if self._local_seed is None or self._remote_seed is None: + self._session = None + else: + self._local_seed_auth_hash = self._klap.handshake1_seed_auth_hash( + self._local_seed, self._remote_seed, self._auth_hash + ) + auth_hash = None + if self._remote_seed_auth_hash is not None: + if self._local_seed_auth_hash == self._remote_seed_auth_hash: + auth_hash = self._auth_hash + else: + auth_hash = self.check_default_credentials() + if not auth_hash: + raise ValueError( + "Local and remote auth hashes do not match. " + "This could mean an incorrect username and/or password." + ) + self._session = MyEncryptionSession( + self._local_seed, self._remote_seed, auth_hash + ) + self._session._seq = self._seq + self._session._generate_cipher() + + @property + def seq(self) -> int: + """Get the sequence number.""" + return self._seq + + @seq.setter + def seq(self, value: int): + if not isinstance(value, int): + raise ValueError("seq must be an integer") + self._seq = value + self.update_encryption_session() + + @property + def local_seed(self) -> bytes | None: + """Get the local seed.""" + return self._local_seed + + @local_seed.setter + def local_seed(self, value: bytes): + print("setting local_seed") + if not isinstance(value, bytes): + raise ValueError("local_seed must be bytes") + elif len(value) != 16: + raise ValueError("local_seed must be 16 bytes") + else: + self._local_seed = value + self._remote_seed_auth_hash = None + self._remote_seed = None + self.update_encryption_session() + + @property + def remote_auth_hash(self) -> bytes | None: + """Get the remote auth hash.""" + return self._remote_seed_auth_hash + + @remote_auth_hash.setter + def remote_auth_hash(self, value: bytes): + print("setting remote_auth_hash") + if not isinstance(value, bytes): + raise ValueError("remote_auth_hash must be bytes") + elif len(value) != 32: + raise ValueError("remote_auth_hash must be 32 bytes") + else: + self._remote_seed_auth_hash = value + self.update_encryption_session() + + @property + def remote_seed(self) -> bytes | None: + """Get the remote seed.""" + return self._remote_seed + + @remote_seed.setter + def remote_seed(self, value: bytes): + print("setting remote_seed") + if not isinstance(value, bytes): + raise ValueError("remote_seed must be bytes") + elif len(value) != 16: + raise ValueError("remote_seed must be 16 bytes") + else: + self._remote_seed = value + self.update_encryption_session() + + # This function decrypts the data using the encryption session. + def decrypt(self, *args, **kwargs): + """Decrypt the data using the encryption session.""" + if self._session is None: + raise ValueError("No session available") + return self._session.decrypt(*args, **kwargs) + + +# This is a custom error handler that replaces bad characters with '*', +# in case something goes wrong in decryption. +# Without this, the decryption could yield an error. +def bad_chars_replacement(exception): + """Replace bad characters with '*'.""" + return ("*", exception.start + 1) + + +codecs.register_error("bad_chars_replacement", bad_chars_replacement) + + +def main( + loop: asyncio.AbstractEventLoop, + username, + password, + device_ip, + source_host, + pcap_file_path, + output_json_name=None, +): + """Run the main function.""" + asyncio.set_event_loop(loop) + capture = pyshark.FileCapture(pcap_file_path, display_filter="http", eventloop=loop) + + # In an effort to keep this code tied into the original code + # (so that this can hopefully leverage any future codebase updates inheriently), + # some weird initialization is done here + creds = Credentials(username, password) + + fake_connection = DeviceConnectionParameters( + DeviceFamily.SmartTapoBulb, DeviceEncryptionType.Klap + ) + fake_device = DeviceConfig( + device_ip, connection_type=fake_connection, credentials=creds + ) + + operator = Operator(KlapTransportV2(config=fake_device), creds) + packets = [] + + # pyshark is a little weird in how it handles iteration, + # so this is a workaround to allow for (advanced) iteration over the packets. + while True: + try: + packet = capture.next() + packet_number = capture._current_packet + if packet.ip.src != source_host: + continue + # we only care about http packets + # this is redundant, as pyshark is set to only load http packets + if not hasattr(packet, "http"): + continue + + uri = packet.http.get("request_uri_path", packet.http.get("request_uri")) + if uri is None: + continue + + operator.seq = _get_seq_from_query(packet) + + # Windows and linux file_data attribute returns different + # pretty format so get the raw field value. + data = packet.http.get_field_value("file_data", raw=True) + + match uri: + case "/app/request": + if packet.ip.dst != device_ip: + continue + message = bytes.fromhex(data) + try: + plaintext = operator.decrypt(message) + payload = json.loads(plaintext) + print(json.dumps(payload, indent=2)) + packets.append(payload) + except ValueError: + print("Insufficient data to decrypt thus far") + + case "/app/handshake1": + if packet.ip.dst != device_ip: + continue + message = bytes.fromhex(data) + operator.local_seed = message + response = None + print( + f"got handshake1 in {packet_number}, looking for the response" + ) + while ( + True + ): # we are going to now look for the response to this request + response = capture.next() + if _is_http_response_for_packet(response, packet): + print(f"found response in {packet_number}") + break + data = response.http.get_field_value("file_data", raw=True) + message = bytes.fromhex(data) + operator.remote_seed = message[0:16] + operator.remote_auth_hash = message[16:] + + case "/app/handshake2": + continue # we don't care about this + case _: + continue + except StopIteration: + break + + # save the final array to a file + if output_json_name is not None: + with open(output_json_name, "w") as f: + f.write(json.dumps(packets, indent=2)) + f.write("\n" * 1) + f.close() + + # Call close method which cleans up event loop + capture.close() + + +@click.command() +@click.option( + "--host", + required=True, + help="the IP of the smart device as it appears in the pcap file.", +) +@click.option( + "--source-host", + required=True, + help="the IP of the device communicating with the smart device.", +) +@click.option( + "--username", + required=True, + envvar="KASA_USERNAME", + help="Username/email address to authenticate to device.", +) +@click.option( + "--password", + required=True, + envvar="KASA_PASSWORD", + help="Password to use to authenticate to device.", +) +@click.option( + "--pcap-file-path", + required=True, + help="The path to the pcap file to parse.", +) +@click.option( + "-o", + "--output", + required=False, + help="The name of the output file, relative to the current directory.", +) +async def cli(username, password, host, source_host, pcap_file_path, output): + """Export KLAP data in JSON format from a PCAP file.""" + # pyshark does not work within a running event loop and we don't want to + # install click as well as asyncclick so run in a new thread. + loop = asyncio.new_event_loop() + thread = Thread( + target=main, + args=[loop, username, password, host, source_host, pcap_file_path, output], + daemon=True, + ) + thread.start() + thread.join() + + +if __name__ == "__main__": + cli() diff --git a/devtools/perftest.py b/devtools/perftest.py new file mode 100644 index 000000000..24c6b0e88 --- /dev/null +++ b/devtools/perftest.py @@ -0,0 +1,94 @@ +"""Script for testing update performance on devices.""" + +import asyncio +import time + +import asyncclick as click +import pandas as pd + +from kasa import Discover + + +async def _update(dev, lock=None): + if lock is not None: + await lock.acquire() + await asyncio.sleep(2) + try: + start_time = time.time() + # print("%s >> Updating" % id(dev)) + await dev.update() + # print("%s >> done in %s" % (id(dev), time.time() - start_time)) + return {"id": f"{id(dev)}-{dev.model}", "took": (time.time() - start_time)} + finally: + if lock is not None: + lock.release() + + +async def _update_concurrently(devs): + start_time = time.time() + update_futures = [asyncio.ensure_future(_update(dev)) for dev in devs] + await asyncio.gather(*update_futures) + return {"type": "concurrently", "took": (time.time() - start_time)} + + +async def _update_sequentially(devs): + start_time = time.time() + + for dev in devs: + await _update(dev) + + return {"type": "sequential", "took": (time.time() - start_time)} + + +@click.command() +@click.argument("addrs", nargs=-1) +@click.option("--rounds", default=5) +async def main(addrs, rounds): + """Test update performance on given devices.""" + print(f"Running {rounds} rounds on {addrs}") + devs = [] + + for addr in addrs: + try: + dev = await Discover.discover_single(addr) + devs.append(dev) + except Exception as ex: + print(f"unable to add {addr}: {ex}") + + data = [] + test_gathered = True + + if test_gathered: + print("=== Testing using gather on all devices ===") + for _i in range(rounds): + data.append(await _update_concurrently(devs)) + await asyncio.sleep(2) + + await asyncio.sleep(5) + + for _i in range(rounds): + data.append(await _update_sequentially(devs)) + await asyncio.sleep(2) + + df = pd.DataFrame(data) + print(df.groupby("type").describe()) + + print("=== Testing per-device performance ===") + + futs = [] + data = [] + locks = {dev: asyncio.Lock() for dev in devs} + for _i in range(rounds): + for dev in devs: + futs.append(asyncio.ensure_future(_update(dev, locks[dev]))) + + for fut in asyncio.as_completed(futs): + res = await fut + data.append(res) + + df = pd.DataFrame(data) + print(df.groupby("id").describe()) + + +if __name__ == "__main__": + main(_anyio_backend="asyncio") 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/SUPPORTED.md b/docs/source/SUPPORTED.md new file mode 100644 index 000000000..3ebfbeb29 --- /dev/null +++ b/docs/source/SUPPORTED.md @@ -0,0 +1,3 @@ +```{include} ../../SUPPORTED.md +:relative-docs: doc/source +``` diff --git a/docs/source/cli.rst b/docs/source/cli.rst index 0d1989dbf..7d4eb0806 100644 --- a/docs/source/cli.rst +++ b/docs/source/cli.rst @@ -1,28 +1,77 @@ Command-line usage ================== -The package is shipped with a console tool named kasa, please refer to ``kasa --help`` for detailed usage. -The device to which the commands are sent is chosen by `KASA_HOST` environment variable or passing ``--host
`` as an option. +The package is shipped with a console tool named ``kasa``, refer to ``kasa --help`` for detailed usage. +The device to which the commands are sent is chosen by ``KASA_HOST`` environment variable or passing ``--host
`` as an option. To see what is being sent to and received from the device, specify option ``--debug``. -To avoid discovering the devices when executing commands its type can be passed by specifying either ``--plug`` or ``--bulb``, -if no type is given its type will be discovered automatically with a small delay. -Some commands (such as reading energy meter values and setting color of bulbs) additional parameters are required, -which you can find by adding ``--help`` after the command, e.g. ``kasa emeter --help`` or ``kasa hsv --help``. +To avoid discovering the devices when executing commands its type can be passed as an option (e.g., ``--type plug`` for plugs, ``--type bulb`` for bulbs, ..). +If no type is manually given, its type will be discovered automatically which causes a short delay. +Note that the ``--type`` parameter only works for legacy devices using port 9999. + +To avoid discovering the devices for newer KASA or TAPO devices using port 20002 for discovery the ``--device-family``, ``-encrypt-type`` and optional +``-login-version`` options can be passed and the devices will probably require authentication via ``--username`` and ``--password``. +Refer to ``kasa --help`` for detailed usage. If no command is given, the ``state`` command will be executed to query the device state. +.. note:: + + Some commands (such as reading energy meter values, changing bulb settings, or accessing individual sockets on smart strips) additional parameters are required, + which you can find by adding ``--help`` after the command, e.g. ``kasa --type emeter --help`` or ``kasa --type hsv --help``. + Refer to the device type specific documentation for more details. + +Discovery +********* + +The tool can automatically discover supported devices using a broadcast-based discovery protocol. +This works by sending an UDP datagram on ports 9999 and 20002 to the broadcast address (defaulting to ``255.255.255.255``). + +Newer devices that respond on port 20002 will require TP-Link cloud credentials to be passed (unless they have never been connected +to the TP-Link cloud) or they will report as having failed authentication when trying to query the device. +Use ``--username`` and ``--password`` options to specify credentials. +These values can also be set as environment variables via ``KASA_USERNAME`` and ``KASA_PASSWORD``. + +On multihomed systems, you can use ``--target`` option to specify the broadcast target. +For example, if your devices reside in network ``10.0.0.0/24`` you can use ``kasa --target 10.0.0.255 discover`` to discover them. + +.. note:: + + When no command is specified when invoking ``kasa``, a discovery is performed and the ``state`` command is executed on each discovered device. + Provisioning -~~~~~~~~~~~~ +************ You can provision your device without any extra apps by using the ``kasa wifi`` command: 1. If the device is unprovisioned, connect to its open network -2. Use ``kasa discover`` (or check the routes) to locate the IP address of the device (likely 192.168.0.1) -3. Scan for available networks using ``kasa wifi scan`` -4. Join/change the network using ``kasa wifi join`` command, see ``--help`` for details. +2. Use ``kasa discover`` (or check the routes) to locate the IP address of the device (likely 192.168.0.1, if unprovisioned) +3. Scan for available networks using ``kasa --host 192.168.0.1 wifi scan`` see which networks are visible to the device +4. Join/change the network using ``kasa --host 192.168.0.1 wifi join `` + +As with all other commands, you can also pass ``--help`` to both ``join`` and ``scan`` commands to see the available options. + +.. note:: + + For devices requiring authentication, the device-stored credentials can be changed using + the ``update-credentials`` commands, for example, to match with other cloud-connected devices. + However, note that communications with devices provisioned using this method will stop working + when connected to the cloud. + +.. note:: + + Some commands do not work if the device time is out-of-sync. + You can use ``kasa time sync`` command to set the device time from the system where the command is run. + +.. warning:: + + At least some devices (e.g., Tapo lights L530 and L900) are known to have a watchdog that reboots them every 10 minutes if they are unable to connect to the cloud. + Although the communications are done locally, this will make these devices unavailable for a minute every time the device restarts. + This does not affect other devices to our current knowledge, but you have been warned. + + ``kasa --help`` -~~~~~~~~~~~~~~~ +*************** .. program-output:: kasa --help diff --git a/docs/source/codeinfo.md b/docs/source/codeinfo.md new file mode 100644 index 000000000..3ee91b369 --- /dev/null +++ b/docs/source/codeinfo.md @@ -0,0 +1,26 @@ + +:::{note} +The library is fully async and methods that perform IO need to be run inside an async coroutine. +Code examples assume you are following them inside `asyncio REPL`: +``` + $ python -m asyncio +``` +Or the code is running inside an async function: +```py +import asyncio +from kasa import Discover + +async def main(): + dev = await Discover.discover_single("127.0.0.1",username="un@example.com",password="pw") + await dev.turn_on() + await dev.update() + +if __name__ == "__main__": + asyncio.run(main()) +``` +**All of your code needs to run inside the same event loop so only call `asyncio.run` once.** + +*The main entry point for the API is {meth}`~kasa.Discover.discover` and +{meth}`~kasa.Discover.discover_single` which return Device objects. +Most newer devices require your TP-Link cloud username and password, but this can be omitted for older devices.* +::: diff --git a/docs/source/conf.py b/docs/source/conf.py index 7e718402d..03e44d95a 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -10,9 +10,10 @@ # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # -# import os -# import sys -# sys.path.insert(0, os.path.abspath('.')) +import os +import sys + +sys.path.insert(0, os.path.abspath("..")) # Will find modules in the docs parent # -- Project information ----------------------------------------------------- @@ -31,7 +32,13 @@ "sphinx.ext.autodoc", "sphinx.ext.coverage", "sphinx.ext.viewcode", + "sphinx.ext.todo", "sphinxcontrib.programoutput", + "myst_parser", +] + +myst_enable_extensions = [ + "colon_fence", ] # Add any paths that contain templates here, relative to this directory. @@ -55,16 +62,10 @@ # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] +todo_include_todos = True +myst_heading_anchors = 3 -def setup(app): + +def setup(app): # noqa: ANN201,ANN001 # add copybutton to hide the >>> prompts, see https://github.com/readthedocs/sphinx_rtd_theme/issues/167 app.add_js_file("copybutton.js") - - # see https://github.com/readthedocs/recommonmark/issues/191#issuecomment-622369992 - from m2r import MdInclude - - app.add_config_value("no_underscore_emphasis", False, "env") - app.add_config_value("m2r_parse_relative_links", False, "env") - app.add_config_value("m2r_anonymous_references", False, "env") - app.add_config_value("m2r_disable_inline_math", False, "env") - app.add_directive("mdinclude", MdInclude) diff --git a/docs/source/contribute.md b/docs/source/contribute.md new file mode 100644 index 000000000..8a0603838 --- /dev/null +++ b/docs/source/contribute.md @@ -0,0 +1,86 @@ +# Contributing + +You probably arrived to this page as you are interested in contributing to python-kasa in some form? +All types of contributions are very welcome, so thank you! +This page aims to help you to get started. + +```{contents} Contents + :local: +``` + +## Setting up the development environment + +To get started, simply clone this repository and initialize the development environment. +We are using [uv](https://github.com/astral-sh/uv) for dependency management, so after cloning the repository simply execute +`uv sync` which will install all necessary packages and create a virtual environment for you in `.venv`. + +``` +$ git clone https://github.com/python-kasa/python-kasa.git +$ cd python-kasa +$ uv sync --all-extras +``` + +## Code-style checks + +We use several tools to automatically check all contributions as part of our CI pipeline. +The simplest way to verify that everything is formatted properly +before creating a pull request, consider activating the pre-commit hooks by executing `pre-commit install`. +This will make sure that the checks are passing when you do a commit. + +```{note} +You can also execute the pre-commit hooks on all files by executing `pre-commit run -a` +``` + +## Running tests + +You can run tests on the library by executing `pytest` in the source directory: + +``` +$ uv run pytest kasa +``` + +This will run the tests against the contributed example responses. + +```{note} +You can also execute the tests against a real device using `uv run pytest --ip=
--username= --password=`. +Note that this will perform state changes on the device. +``` + +## Analyzing network captures + +The simplest way to add support for a new device or to improve existing ones is to capture traffic between the mobile app and the device. +After capturing the traffic, you can either use the [softScheck's wireshark dissector](https://github.com/softScheck/tplink-smartplug) +or the `parse_pcap.py` script contained inside the `devtools` directory. +Note, that this works currently only on kasa-branded devices which use port 9999 for communications. + +## Contributing fixture files + +One of the easiest ways to contribute is by creating a fixture file and uploading it for us. +These files will help us to improve the library and run tests against devices that we have no access to. + +This library is tested against responses from real devices ("fixture files"). +These files contain responses for selected, known device commands and are stored [in our test suite](https://github.com/python-kasa/python-kasa/tree/master/tests/fixtures). + +You can generate these files by using the `dump_devinfo.py` script. +Note, that this script should be run inside the main source directory so that the generated files are stored in the correct directories. +The easiest way to do that is by doing: + +``` +$ git clone https://github.com/python-kasa/python-kasa.git +$ cd python-kasa +$ uv sync --all-extras +$ source .venv/bin/activate +$ python -m devtools.dump_devinfo --username --password --host 192.168.1.123 +``` + +```{note} +You can also execute the script against a network by using `--target`: `python -m devtools.dump_devinfo --target 192.168.1.255` +``` + +The script will run queries against the device, and prompt at the end if you want to save the results. +If you choose to do so, it will save the fixture files directly in their correct place to make it easy to create a pull request. + +```{note} +When adding new fixture files, you should run `pre-commit run -a` to re-generate the list of supported devices. +You may need to adjust `device_fixtures.py` to add a new model into the correct device categories. Verify that test pass by executing `uv run pytest kasa`. +``` diff --git a/docs/source/deprecated.md b/docs/source/deprecated.md new file mode 100644 index 000000000..f27c09855 --- /dev/null +++ b/docs/source/deprecated.md @@ -0,0 +1,67 @@ +# 0.7 API changes + +This page contains information about the major API changes in 0.7. + +The previous API reference can be found below. + +## Restructuring the library + +This is the largest refactoring of the library and there are changes in all parts of the library. +Other than the three breaking changes below, all changes are backwards compatible, and you will get a deprecation warning with instructions to help porting your code over. + +* The library has now been restructured into `iot` and `smart` packages to contain the respective protocol (command set) implementations. The old `Smart{Plug,Bulb,Lightstrip}` that do not require authentication are now accessible through `kasa.iot` package. +* Exception classes are renamed +* Using .connect() or discover() is the preferred way to construct device instances rather than initiating constructors on a device. + +### Breaking changes + +* `features()` now returns a dict of `(identifier, feature)` instead of barely used set of strings. +* The `supported_modules` attribute is removed from the device class. +* `state_information` returns information based on features. If you leveraged this property, you may need to adjust your keys. + +## Module support for SMART devices + +This release introduces modules to SMART devices (i.e., devices that require authentication, previously supported using the "tapo" package which has now been renamed to "smart") and uses the device-reported capabilities to initialize the modules supported by the device. +This allows us to support previously unknown devices for known and implemented features, +and makes it easy to add support for new features and device types in the future. + +This inital release adds 26 modules to support a variety of features, including: +* Basic controls for various device (like color temperature, brightness, etc.) +* Light effects & presets +* Control LEDs +* Fan controls +* Thermostat controls +* Handling of firmware updates +* Some hub controls (like playing alarms, ) + +## Introspectable device features + +The library now offers a generic way to access device features ("features"), making it possible to create interfaces without knowledge of the module/feature specific APIs. +We use this information to construct our cli tool status output, and you can use `kasa feature` to read and control them. + +The upcoming homeassistant integration rewrite will also use these interfaces to provide access to features that were not easily available to homeassistant users, and simplifies extending the support for more devices and features in the future. + +## Deprecated API Reference + +```{currentmodule} kasa +``` +The page contains the documentation for the deprecated library API that only works with the older kasa devices. + +If you want to continue to use the old API for older devices, +you can use the classes in the `iot` module to avoid deprecation warnings. + +```py +from kasa.iot import IotDevice, IotBulb, IotPlug, IotDimmer, IotStrip, IotLightStrip +``` + + +```{toctree} +:maxdepth: 2 + +smartdevice +smartbulb +smartplug +smartdimmer +smartstrip +smartlightstrip +``` diff --git a/docs/source/discover.rst b/docs/source/discover.rst deleted file mode 100644 index f47f50d72..000000000 --- a/docs/source/discover.rst +++ /dev/null @@ -1,17 +0,0 @@ -Discovering devices -=================== - -.. code-block:: - - import asyncio - from kasa import Discover - - devices = asyncio.run(Discover.discover()) - for addr, dev in devices.items(): - asyncio.run(dev.update()) - print(f"{addr} >> {dev}") - - -.. autoclass:: kasa.Discover - :members: - :undoc-members: 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.md b/docs/source/guides.md new file mode 100644 index 000000000..75b1424b4 --- /dev/null +++ b/docs/source/guides.md @@ -0,0 +1,16 @@ +# How-to Guides + +Guides of how to perform common actions using the library. + +```{toctree} +:maxdepth: 2 + +guides/discover +guides/connect +guides/device +guides/module +guides/feature +guides/light +guides/strip +guides/energy +``` diff --git a/docs/source/guides/connect.md b/docs/source/guides/connect.md new file mode 100644 index 000000000..9336a1c14 --- /dev/null +++ b/docs/source/guides/connect.md @@ -0,0 +1,10 @@ +(connect_target)= +# Connect without discovery + +:::{include} ../codeinfo.md +::: + +```{eval-rst} +.. automodule:: kasa.deviceconfig + :noindex: +``` diff --git a/docs/source/guides/device.md b/docs/source/guides/device.md new file mode 100644 index 000000000..c2fbfb74b --- /dev/null +++ b/docs/source/guides/device.md @@ -0,0 +1,10 @@ +(device_target)= +# Interact with devices + +:::{include} ../codeinfo.md +::: + +```{eval-rst} +.. automodule:: kasa.device + :noindex: +``` diff --git a/docs/source/guides/discover.md b/docs/source/guides/discover.md new file mode 100644 index 000000000..2d50c4c68 --- /dev/null +++ b/docs/source/guides/discover.md @@ -0,0 +1,11 @@ +(discover_target)= +# Discover devices + +:::{include} ../codeinfo.md +::: + + +```{eval-rst} +.. automodule:: kasa.discover + :noindex: +``` diff --git a/docs/source/guides/energy.md b/docs/source/guides/energy.md new file mode 100644 index 000000000..a177cd1ad --- /dev/null +++ b/docs/source/guides/energy.md @@ -0,0 +1,31 @@ + +# 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. +::: + +## Energy Consumption + +The availability of energy consumption sensors depend on the device. +While most of the bulbs support it, only specific switches (e.g., HS110) or strips (e.g., HS300) support it. +You can use {attr}`~Device.has_emeter` to check for the availability. + + +## Usage statistics + +You can use {attr}`~Device.on_since` to query for the time the device has been turned on. +Some devices also support reporting the usage statistics on daily or monthly basis. +You can access this information using through the usage module ({class}`kasa.modules.Usage`): + +```py +dev = SmartPlug("127.0.0.1") +usage = dev.modules["usage"] +print(f"Minutes on this month: {usage.usage_this_month}") +print(f"Minutes on today: {usage.usage_today}") +``` diff --git a/docs/source/guides/feature.md b/docs/source/guides/feature.md new file mode 100644 index 000000000..307f52a6c --- /dev/null +++ b/docs/source/guides/feature.md @@ -0,0 +1,10 @@ +(feature_target)= +# Interact with features + +:::{include} ../codeinfo.md +::: + +```{eval-rst} +.. automodule:: kasa.feature + :noindex: +``` diff --git a/docs/source/guides/light.md b/docs/source/guides/light.md new file mode 100644 index 000000000..c8b72a997 --- /dev/null +++ b/docs/source/guides/light.md @@ -0,0 +1,26 @@ +(light_target)= +# Interact with lights + +:::{include} ../codeinfo.md +::: + +```{eval-rst} +.. automodule:: kasa.interfaces.light + :noindex: +``` + +(lightpreset_target)= +## Presets + +```{eval-rst} +.. automodule:: kasa.interfaces.lightpreset + :noindex: +``` + +(lighteffect_target)= +## Effects + +```{eval-rst} +.. automodule:: kasa.interfaces.lighteffect + :noindex: +``` diff --git a/docs/source/guides/module.md b/docs/source/guides/module.md new file mode 100644 index 000000000..a001cf505 --- /dev/null +++ b/docs/source/guides/module.md @@ -0,0 +1,10 @@ +(module_target)= +# Interact with modules + +:::{include} ../codeinfo.md +::: + +```{eval-rst} +.. automodule:: kasa.module + :noindex: +``` diff --git a/docs/source/guides/strip.md b/docs/source/guides/strip.md new file mode 100644 index 000000000..b6e914cc4 --- /dev/null +++ b/docs/source/guides/strip.md @@ -0,0 +1,17 @@ +(child_target)= +# Interact with child devices + +:::{include} ../codeinfo.md +::: + +```{eval-rst} +.. automodule:: kasa.smart.modules.childdevice + :noindex: +``` + +## Pairing and unpairing + +```{eval-rst} +.. automodule:: kasa.interfaces.childsetup + :noindex: +``` diff --git a/docs/source/index.md b/docs/source/index.md new file mode 100644 index 000000000..e1ba08332 --- /dev/null +++ b/docs/source/index.md @@ -0,0 +1,12 @@ +```{include} ../../README.md +``` + +```{toctree} +:maxdepth: 2 + +Home +cli +library +contribute +SUPPORTED +``` diff --git a/docs/source/index.rst b/docs/source/index.rst deleted file mode 100644 index 59897b394..000000000 --- a/docs/source/index.rst +++ /dev/null @@ -1,18 +0,0 @@ -python-kasa documentation -========================= - -.. mdinclude:: ../../README.md - -.. toctree:: - :maxdepth: 2 - - - Home - cli - discover - smartdevice - smartbulb - smartplug - smartdimmer - smartstrip - smartlightstrip diff --git a/docs/source/library.md b/docs/source/library.md new file mode 100644 index 000000000..fa276a1b0 --- /dev/null +++ b/docs/source/library.md @@ -0,0 +1,15 @@ +# Library usage + +```{currentmodule} kasa +``` +The page contains all information about the library usage: + +```{toctree} +:maxdepth: 2 + +tutorial +guides +topics +reference +deprecated +``` diff --git a/docs/source/reference.md b/docs/source/reference.md new file mode 100644 index 000000000..90493c9c2 --- /dev/null +++ b/docs/source/reference.md @@ -0,0 +1,135 @@ +# API Reference + +## Discover + + +```{module} kasa +``` + +```{eval-rst} +.. autoclass:: Discover + :members: +``` + +## Device + +% N.B. Credentials clashes with autodoc + +```{eval-rst} +.. autoclass:: Device + :members: + :undoc-members: + :exclude-members: Credentials +``` + + +## Device Config + + +```{eval-rst} +.. autoclass:: Credentials + :members: + :undoc-members: +``` + + +```{eval-rst} +.. autoclass:: DeviceConfig + :members: + :undoc-members: +``` + + +```{eval-rst} +.. autoclass:: DeviceFamily + :members: + :undoc-members: +``` + +```{eval-rst} +.. autoclass:: DeviceConnectionParameters + :members: + :undoc-members: +``` + +```{eval-rst} +.. autoclass:: DeviceEncryptionType + :members: + :undoc-members: +``` + +## Modules and Features + +```{eval-rst} +.. autoclass:: Module + :members: +``` + +```{eval-rst} +.. autoclass:: Feature + :members: + :inherited-members: + :undoc-members: +``` + +```{eval-rst} +.. automodule:: kasa.interfaces + :members: + :inherited-members: + :undoc-members: +``` + +## Protocols and transports + + +```{eval-rst} +.. automodule:: kasa.protocols + :members: + :imported-members: + :undoc-members: + :exclude-members: SmartErrorCode + :no-index: +``` + +```{eval-rst} +.. automodule:: kasa.transports + :members: + :imported-members: + :undoc-members: + :no-index: +``` + + +## Errors and exceptions + + + +```{eval-rst} +.. autoclass:: kasa.exceptions.KasaException + :members: + :undoc-members: +``` + +```{eval-rst} +.. autoclass:: kasa.exceptions.DeviceError + :members: + :undoc-members: +``` + +```{eval-rst} +.. autoclass:: kasa.exceptions.AuthenticationError + :members: + :undoc-members: +``` + +```{eval-rst} +.. autoclass:: kasa.exceptions.UnsupportedDeviceError + :members: + :undoc-members: +``` + +```{eval-rst} +.. autoclass:: kasa.exceptions.TimeoutError + :members: + :undoc-members: +``` diff --git a/docs/source/smartbulb.rst b/docs/source/smartbulb.rst index 76f66224e..8fae54d17 100644 --- a/docs/source/smartbulb.rst +++ b/docs/source/smartbulb.rst @@ -1,6 +1,79 @@ Bulbs =========== +.. contents:: Contents + :local: + +Supported features +****************** + +* Turning on and off +* Setting brightness, color temperature, and color (in HSV) +* Querying emeter information +* Transitions +* Presets + +Currently unsupported +********************* + +* Setting the default transitions +* Timers + +.. note:: + + Feel free to open a pull request to add support for more features! + +Transitions +*********** + +All commands changing the bulb state can be accompanied with a transition, e.g., to slowly fade the light off. +The transition time is in milliseconds, 0 means immediate change. +If no transition value is given, the default setting as configured for the bulb will be used. + +.. note:: + + Accepted values are command (and potentially bulb) specific, feel free to improve the documentation on accepted values. + + **Example:** While KL130 allows at least up to 15 second transitions for smooth turning off transitions, turning it on will not be so smooth. + +Command-line usage +****************** + +All command-line commands can be used with transition period for smooth changes. + + +**Example:** Turn the bulb off over a 15 second time period. + +.. code:: + + $ kasa --type bulb --host off --transition 15000 + +**Example:** Change the bulb to red with 20% brightness over 15 seconds: + +.. code:: + + $ kasa --type bulb --host hsv 0 100 20 --transition 15000 + + +API documentation +***************** + .. autoclass:: kasa.SmartBulb + :members: + :inherited-members: + :undoc-members: + +.. autoclass:: kasa.SmartBulbPreset :members: :undoc-members: + +.. autoclass:: kasa.iot.iotbulb.BehaviorMode + :members: + +.. autoclass:: kasa.iot.iotbulb.TurnOnBehaviors + :members: + + +.. autoclass:: kasa.iot.iotbulb.TurnOnBehavior + :undoc-members: + :members: diff --git a/docs/source/smartdevice.rst b/docs/source/smartdevice.rst index dd08ac911..0f91642c5 100644 --- a/docs/source/smartdevice.rst +++ b/docs/source/smartdevice.rst @@ -1,12 +1,26 @@ -Common API -====================== +.. py:currentmodule:: kasa + +Base Device +=========== + +.. contents:: Contents + :local: + +SmartDevice class +***************** The basic functionalities of all supported devices are accessible using the common :class:`SmartDevice` base class. -The property accesses use the data obtained before by awaiting :func:`update()`. +The property accesses use the data obtained before by awaiting :func:`SmartDevice.update()`. The values are cached until the next update call. In practice this means that property accesses do no I/O and are dependent, while I/O producing methods need to be awaited. +See :ref:`topics-update-cycle` for more detailed information. + +.. note:: + The device instances share the communication socket in background to optimize I/O accesses. + This means that you need to use the same event loop for subsequent requests. + The library gives a warning ("Detected protocol reuse between different event loop") to hint if you are accessing the device incorrectly. -Methods changing the state of the device do not invalidate the cache (i.e., there is no implicit `update()`). +Methods changing the state of the device do not invalidate the cache (i.e., there is no implicit :func:`SmartDevice.update()` call made by the library). You can assume that the operation has succeeded if no exception is raised. These methods will return the device response, which can be useful for some use cases. @@ -22,24 +36,70 @@ Simple example script showing some functionality: async def main(): p = SmartPlug("127.0.0.1") - await p.update() - print(p.alias) + await p.update() # Request the update + print(p.alias) # Print out the alias + print(p.emeter_realtime) # Print out current emeter status - await p.turn_off() + await p.turn_off() # Turn the device off + + if __name__ == "__main__": + asyncio.run(main()) +If you want to perform updates in a loop, you need to make sure that the device accesses are done in the same event loop: + +.. code-block:: python + + import asyncio + from kasa import SmartPlug + + async def main(): + dev = SmartPlug("127.0.0.1") # We create the instance inside the main loop + while True: + await dev.update() # Request an update + print(dev.emeter_realtime) + await asyncio.sleep(0.5) # Sleep some time between updates if __name__ == "__main__": asyncio.run(main()) Refer to device type specific classes for more examples: +:class:`SmartPlug`, :class:`SmartBulb`, :class:`SmartStrip`, +:class:`SmartDimmer`, :class:`SmartLightStrip`. + +Energy Consumption and Usage Statistics +*************************************** + +.. 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 and public servers from `NTP Pool Project `_ to synchronize their time. + +Energy Consumption +~~~~~~~~~~~~~~~~~~ + +The availability of energy consumption sensors depend on the device. +While most of the bulbs support it, only specific switches (e.g., HS110) or strips (e.g., HS300) support it. +You can use :attr:`~SmartDevice.has_emeter` to check for the availability. + + +Usage statistics +~~~~~~~~~~~~~~~~ + +You can use :attr:`~SmartDevice.on_since` to query for the time the device has been turned on. +Some devices also support reporting the usage statistics on daily or monthly basis. +You can access this information using through the usage module (:class:`kasa.modules.Usage`): + +.. code-block:: python + + dev = SmartPlug("127.0.0.1") + usage = dev.modules["usage"] + print(f"Minutes on this month: {usage.usage_this_month}") + print(f"Minutes on today: {usage.usage_today}") + -* :class:`SmartPlug` -* :class:`SmartBulb` -* :class:`SmartStrip` -* :class:`SmartDimmer` -* :class:`SmartLightStrip` +API documentation +***************** -.. autoclass:: kasa.SmartDevice +.. autoclass:: SmartDevice :members: :undoc-members: diff --git a/docs/source/smartdimmer.rst b/docs/source/smartdimmer.rst index f55d571cf..62d701422 100644 --- a/docs/source/smartdimmer.rst +++ b/docs/source/smartdimmer.rst @@ -1,6 +1,18 @@ Dimmers ======= +.. contents:: Contents + :local: + + +.. note:: + + Feel free to open a pull request to improve the documentation! + +API documentation +***************** + .. autoclass:: kasa.SmartDimmer :members: + :inherited-members: :undoc-members: diff --git a/docs/source/smartlightstrip.rst b/docs/source/smartlightstrip.rst index b02342ed9..d0d99ce48 100644 --- a/docs/source/smartlightstrip.rst +++ b/docs/source/smartlightstrip.rst @@ -1,6 +1,17 @@ Light strips ============ +.. contents:: Contents + :local: + +.. note:: + + Feel free to open a pull request to improve the documentation! + +API documentation +***************** + .. autoclass:: kasa.SmartLightStrip :members: + :inherited-members: :undoc-members: diff --git a/docs/source/smartplug.rst b/docs/source/smartplug.rst index 75b342cb0..ff562c93d 100644 --- a/docs/source/smartplug.rst +++ b/docs/source/smartplug.rst @@ -1,6 +1,18 @@ Plugs ===== +.. contents:: Contents + :local: + +.. note:: + + Feel free to open a pull request to improve the documentation! + + +API documentation +***************** + .. autoclass:: kasa.SmartPlug :members: + :inherited-members: :undoc-members: diff --git a/docs/source/smartstrip.rst b/docs/source/smartstrip.rst index b6c9ff903..5c41be51d 100644 --- a/docs/source/smartstrip.rst +++ b/docs/source/smartstrip.rst @@ -1,6 +1,36 @@ Smart strips ============ +.. contents:: Contents + :local: + +.. note:: + + Feel free to open a pull request to improve the documentation! + +Command-line usage +****************** + +To command a single socket of a strip, you will need to specify it either by using ``--index`` or by using ``--name``. +If not specified, the commands will act on the parent device: turning the strip off will turn off all sockets. + +**Example:** Turn on the first socket (the indexing starts from zero): + +.. code:: + + $ kasa --type strip --host on --index 0 + +**Example:** Turn off the socket by name: + +.. code:: + + $ kasa --type strip --host off --name "Maybe Kitchen" + + +API documentation +***************** + .. autoclass:: kasa.SmartStrip :members: + :inherited-members: :undoc-members: diff --git a/docs/source/topics.md b/docs/source/topics.md new file mode 100644 index 000000000..f7d0cdd50 --- /dev/null +++ b/docs/source/topics.md @@ -0,0 +1,142 @@ + +# Topics + +```{contents} Contents + :local: +``` + +These topics aim to provide some details on the design and internals of this library. +You might be interested in this if you want to improve this library, +or if you are just looking to access some information that is not currently exposed. + +(topics-initialization)= +## Initialization + +Use {func}`~kasa.Discover.discover` to perform udp-based broadcast discovery on the network. +This will return you a list of device instances based on the discovery replies. + +If the device's host is already known, you can use to construct a device instance with +{meth}`~kasa.Device.connect()`. + +The {meth}`~kasa.Device.connect()` also enables support for connecting to new +KASA SMART protocol and TAPO devices directly using the parameter {class}`~kasa.DeviceConfig`. +Simply serialize the {attr}`~kasa.Device.config` property via {meth}`~kasa.DeviceConfig.to_dict()` +and then deserialize it later with {func}`~kasa.DeviceConfig.from_dict()` +and then pass it into {meth}`~kasa.Device.connect()`. + + +(topics-discovery)= +## Discovery + +Discovery works by sending broadcast UDP packets to two known TP-link discovery ports, 9999 and 20002. +Port 9999 is used for legacy devices that do not use strong encryption and 20002 is for newer devices that use different +levels of encryption. +If a device uses port 20002 for discovery you will obtain some basic information from the device via discovery, but you +will need to await {func}`Device.update() ` to get full device information. +Credentials will most likely be required for port 20002 devices although if the device has never been connected to the tplink +cloud it may work without credentials. + +To query or update the device requires authentication via {class}`Credentials ` and if this is invalid or not provided it +will raise an {class}`AuthenticationException `. + +If discovery encounters an unsupported device when calling via {meth}`Discover.discover_single() ` +it will raise a {class}`UnsupportedDeviceException `. +If discovery encounters a device when calling {func}`Discover.discover() `, +you can provide a callback to the ``on_unsupported`` parameter +to handle these. + +(topics-deviceconfig)= +## DeviceConfig + +The {class}`DeviceConfig` class can be used to initialise devices with parameters to allow them to be connected to without using +discovery. +This is required for newer KASA and TAPO devices that use different protocols for communication and will not respond +on port 9999 but instead use different encryption protocols over http port 80. +Currently there are three known types of encryption for TP-Link devices and two different protocols. +Devices with automatic firmware updates enabled may update to newer versions of the encryption without separate notice, +so discovery can be helpful to determine the correct config. + +To connect directly pass a {class}`DeviceConfig` object to {meth}`Device.connect()`. + +A {class}`DeviceConfig` can be constucted manually if you know the {attr}`DeviceConfig.connection_type` values for the device or +alternatively the config can be retrieved from {attr}`Device.config` post discovery and then re-used. + +(topics-update-cycle)= +## Update Cycle + +When {meth}`~kasa.Device.update()` is called, +the library constructs a query to send to the device based on :ref:`supported modules `. +Internally, each module defines {meth}`~kasa.modules.Module.query()` to describe what they want query during the update. + +The returned data is cached internally to avoid I/O on property accesses. +All properties defined both in the device class and in the module classes follow this principle. + +While the properties are designed to provide a nice API to use for common use cases, +you may sometimes want to access the raw, cached data as returned by the device. +This can be done using the {attr}`~kasa.Device.internal_state` property. + + +(topics-modules-and-features)= +## Modules and Features + +The functionality provided by all {class}`~kasa.Device` instances is (mostly) done inside separate 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. + +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 + +The library supports two different TP-Link protocols, ``IOT`` and ``SMART``. +``IOT`` is the original Kasa protocol and ``SMART`` is the newer protocol supported by TAPO devices and newer KASA devices. +The original protocol has a ``target``, ``command``, ``args`` interface whereas the new protocol uses a different set of +commands and has a ``method``, ``parameters`` interface. +Confusingly TP-Link originally called the Kasa line "Kasa Smart" and hence this library used "Smart" in a lot of the +module and class names but actually they were built to work with the ``IOT`` protocol. + +In 2021 TP-Link started updating the underlying communication transport used by Kasa devices to make them more secure. +It switched from a TCP connection with static XOR type of encryption to a transport called ``KLAP`` which communicates +over http and uses handshakes to negotiate a dynamic encryption cipher. +This automatic update was put on hold and only seemed to affect UK HS100 models. + +In 2023 TP-Link started updating the underlying communication transport used by Tapo devices to make them more secure. +It switched from AES encryption via public key exchange to use ``KLAP`` encryption and negotiation due to concerns +around impersonation with AES. +The encryption cipher is the same as for Kasa KLAP but the handshake seeds are slightly different. +Also in 2023 TP-Link started releasing newer Kasa branded devices using the ``SMART`` protocol. +This appears to be driven by hardware version rather than firmware. + + +In order to support these different configurations the library migrated from a single protocol class ``TPLinkSmartHomeProtocol`` +to support pluggable transports and protocols. +The classes providing this functionality are: + +- {class}`BaseProtocol ` +- {class}`IotProtocol ` +- {class}`SmartProtocol ` + +- {class}`BaseTransport ` +- {class}`XorTransport ` +- {class}`AesTransport ` +- {class}`KlapTransport ` +- {class}`KlapTransportV2 ` + +(topics-errors-and-exceptions)= +## Errors and Exceptions + +The base exception for all library errors is {class}`KasaException `. + +- If the device returns an error the library raises a {class}`DeviceError ` which will usually contain an ``error_code`` with the detail. +- If the device fails to authenticate the library raises an {class}`AuthenticationError ` which is derived + from {class}`DeviceError ` and could contain an ``error_code`` depending on the type of failure. +- If the library encounters and unsupported deviceit raises an {class}`UnsupportedDeviceError `. +- 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/source/tutorial.md b/docs/source/tutorial.md new file mode 100644 index 000000000..30944dd57 --- /dev/null +++ b/docs/source/tutorial.md @@ -0,0 +1,11 @@ +# Getting started + +:::{include} codeinfo.md +::: + +```{eval-rst} +.. automodule:: tutorial + :members: + :inherited-members: + :undoc-members: +``` diff --git a/docs/tutorial.py b/docs/tutorial.py new file mode 100644 index 000000000..1f27ddc17 --- /dev/null +++ b/docs/tutorial.py @@ -0,0 +1,96 @@ +# ruff: noqa +""" +>>> from kasa import Discover + +:func:`~kasa.Discover.discover` returns a dict[str,Device] of devices on your network: + +>>> devices = await Discover.discover(username="user@example.com", password="great_password") +>>> for dev in devices.values(): +>>> 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: + +>>> dev = await Discover.discover_single("127.0.0.3", username="user@example.com", password="great_password") +>>> await dev.update() +>>> dev.alias +Living Room Bulb +>>> dev.model +L530 +>>> dev.rssi +-52 +>>> dev.mac +5C:E9:31:00:00:00 + +You can update devices by calling different methods (e.g., ``set_``-prefixed ones). +Note, that these do not update the internal state, but you need to call :meth:`~kasa.Device.update()` to query the device again. +back to the device. + +>>> await dev.set_alias("Dining Room") +>>> await dev.update() +>>> dev.alias +Dining Room + +Different groups of functionality are supported by modules which you can access via :attr:`~kasa.Device.modules` with a typed +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 ``has_feature()`` method. + +>>> from kasa import Module +>>> Module.Light in dev.modules +True +>>> light = dev.modules[Module.Light] +>>> light.brightness +100 +>>> await light.set_brightness(50) +>>> await dev.update() +>>> light.brightness +50 +>>> light.has_feature("hsv") +True +>>> 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) +Off +['Off', 'Party', 'Relax'] +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. + +Features are similar to modules in that they provide functionality that may or may not be present. + +Whereas modules group functionality into a common interface, features expose a single function that may or may not be part of a module. + +The advantage of features is that they have a simple common interface of `id`, `name`, `value` and `set_value` so no need to learn the module API. + +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) +False +>>> if auto_update: +>>> 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#\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 911a7dc39..b8871f997 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -1,40 +1,157 @@ """Python interface for TP-Link's smart home devices. -All common, shared functionalities are available through `SmartDevice` class:: +All common, shared functionalities are available through `Device` class:: - x = SmartDevice("192.168.1.1") - print(x.sys_info) +>>> from kasa import Discover +>>> x = await Discover.discover_single("192.168.1.1") +>>> print(x.model) -For device type specific actions `SmartBulb`, `SmartPlug`, or `SmartStrip` - should be used instead. +For device type specific actions `modules` and `features` should be used instead. -Module-specific errors are raised as `SmartDeviceException` and are expected +Module-specific errors are raised as `KasaException` and are expected to be handled by the user of the library. """ -from importlib_metadata import version # type: ignore + +from importlib.metadata import version +from typing import TYPE_CHECKING, Any +from warnings import warn + +from kasa.credentials import Credentials +from kasa.device import Device +from kasa.device_type import DeviceType +from kasa.deviceconfig import ( + DeviceConfig, + DeviceConnectionParameters, + DeviceEncryptionType, + DeviceFamily, +) from kasa.discover import Discover -from kasa.exceptions import SmartDeviceException -from kasa.protocol import TPLinkSmartHomeProtocol -from kasa.smartbulb import SmartBulb -from kasa.smartdevice import DeviceType, EmeterStatus, SmartDevice -from kasa.smartdimmer import SmartDimmer -from kasa.smartlightstrip import SmartLightStrip -from kasa.smartplug import SmartPlug -from kasa.smartstrip import SmartStrip +from kasa.emeterstatus import EmeterStatus +from kasa.exceptions import ( + AuthenticationError, + DeviceError, + KasaException, + TimeoutError, + UnsupportedDeviceError, +) +from kasa.feature import Feature +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, 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") __all__ = [ "Discover", - "TPLinkSmartHomeProtocol", - "SmartBulb", + "BaseProtocol", + "BaseTransport", + "IotProtocol", + "SmartProtocol", + "SmartCamProtocol", + "LightState", + "TurnOnBehaviors", + "TurnOnBehavior", "DeviceType", + "Feature", "EmeterStatus", - "SmartDevice", - "SmartDeviceException", - "SmartPlug", - "SmartStrip", - "SmartDimmer", - "SmartLightStrip", + "Device", + "Light", + "ColorTempRange", + "HSV", + "Plug", + "Module", + "KasaException", + "AuthenticationError", + "DeviceError", + "UnsupportedDeviceError", + "TimeoutError", + "Credentials", + "DeviceConfig", + "DeviceConnectionParameters", + "DeviceEncryptionType", + "DeviceFamily", + "ThermostatState", + "Thermostat", + "StreamResolution", ] + +from . import iot +from .iot.modules.lightpreset import IotLightPreset + +deprecated_names = ["TPLinkSmartHomeProtocol"] +deprecated_smart_devices = { + "SmartDevice": iot.IotDevice, + "SmartPlug": iot.IotPlug, + "SmartBulb": iot.IotBulb, + "SmartLightStrip": iot.IotLightStrip, + "SmartStrip": iot.IotStrip, + "SmartDimmer": iot.IotDimmer, + "SmartBulbPreset": IotLightPreset, +} +deprecated_classes = { + "SmartDeviceException": KasaException, + "UnsupportedDeviceException": UnsupportedDeviceError, + "AuthenticationException": AuthenticationError, + "TimeoutException": TimeoutError, + "ConnectionType": DeviceConnectionParameters, + "EncryptType": DeviceEncryptionType, + "DeviceFamilyType": DeviceFamily, +} + +if not TYPE_CHECKING: + + def __getattr__(name: str) -> Any: + if name in deprecated_names: + warn(f"{name} is deprecated", DeprecationWarning, stacklevel=2) + return globals()[f"_deprecated_{name}"] + if name in deprecated_smart_devices: + new_class = deprecated_smart_devices[name] + package_name = ".".join(new_class.__module__.split(".")[:-1]) + warn( + f"{name} is deprecated, use {new_class.__name__} from " + + f"package {package_name} instead or use Discover.discover_single()" + + " and Device.connect() to support new protocols", + DeprecationWarning, + stacklevel=2, + ) + return new_class + if name in deprecated_classes: + new_class = deprecated_classes[name] # type: ignore[assignment] + msg = f"{name} is deprecated, use {new_class.__name__} instead" + warn(msg, DeprecationWarning, stacklevel=2) + return new_class + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +if TYPE_CHECKING: + SmartDevice = Device + SmartBulb = iot.IotBulb + SmartPlug = iot.IotPlug + SmartLightStrip = iot.IotLightStrip + SmartStrip = iot.IotStrip + SmartDimmer = iot.IotDimmer + SmartBulbPreset = IotLightPreset + + SmartDeviceException = KasaException + UnsupportedDeviceException = UnsupportedDeviceError + AuthenticationException = AuthenticationError + TimeoutException = TimeoutError + ConnectionType = DeviceConnectionParameters + EncryptType = DeviceEncryptionType + DeviceFamilyType = DeviceFamily + + # Instanstiate all classes so the type checkers catch abstract issues + from . import smart + + smart.SmartDevice("127.0.0.1") + iot.IotDevice("127.0.0.1") + iot.IotPlug("127.0.0.1") + iot.IotBulb("127.0.0.1") + iot.IotLightStrip("127.0.0.1") + iot.IotStrip("127.0.0.1") + iot.IotDimmer("127.0.0.1") diff --git a/kasa/cachedzoneinfo.py b/kasa/cachedzoneinfo.py new file mode 100644 index 000000000..f3f5f4412 --- /dev/null +++ b/kasa/cachedzoneinfo.py @@ -0,0 +1,27 @@ +"""Module for caching ZoneInfos.""" + +from __future__ import annotations + +import asyncio +from zoneinfo import ZoneInfo + + +class CachedZoneInfo(ZoneInfo): + """Cache ZoneInfo objects.""" + + _cache: dict[str, ZoneInfo] = {} + + @classmethod + async def get_cached_zone_info(cls, time_zone_str: str) -> ZoneInfo: + """Get a cached zone info object.""" + if cached := cls._cache.get(time_zone_str): + return cached + loop = asyncio.get_running_loop() + zinfo = await loop.run_in_executor(None, _get_zone_info, time_zone_str) + cls._cache[time_zone_str] = zinfo + return zinfo + + +def _get_zone_info(time_zone_str: str) -> ZoneInfo: + """Get a time zone object for the given time zone string.""" + return ZoneInfo(time_zone_str) diff --git a/kasa/cli.py b/kasa/cli.py deleted file mode 100755 index 167179e36..000000000 --- a/kasa/cli.py +++ /dev/null @@ -1,489 +0,0 @@ -"""python-kasa cli tool.""" -import json -import logging -import re -from pprint import pformat as pf -from typing import cast - -import asyncclick as click - -from kasa import ( - Discover, - SmartBulb, - SmartDevice, - SmartLightStrip, - SmartPlug, - SmartStrip, -) - -click.anyio_backend = "asyncio" - - -pass_dev = click.make_pass_decorator(SmartDevice) - - -@click.group(invoke_without_command=True) -@click.option( - "--host", - envvar="KASA_HOST", - required=False, - help="The host name or IP address of the device to connect to.", -) -@click.option( - "--alias", - envvar="KASA_NAME", - required=False, - help="The device name, or alias, of the device to connect to.", -) -@click.option( - "--target", - default="255.255.255.255", - required=False, - help="The broadcast address to be used for discovery.", -) -@click.option("-d", "--debug", default=False, is_flag=True) -@click.option("--bulb", default=False, is_flag=True) -@click.option("--plug", default=False, is_flag=True) -@click.option("--lightstrip", default=False, is_flag=True) -@click.option("--strip", default=False, is_flag=True) -@click.version_option() -@click.pass_context -async def cli(ctx, host, alias, target, debug, bulb, plug, lightstrip, strip): - """A tool for controlling TP-Link smart home devices.""" # noqa - if debug: - logging.basicConfig(level=logging.DEBUG) - else: - logging.basicConfig(level=logging.INFO) - - if ctx.invoked_subcommand == "discover": - return - - if alias is not None and host is None: - click.echo(f"Alias is given, using discovery to find host {alias}") - host = await find_host_from_alias(alias=alias, target=target) - if host: - click.echo(f"Found hostname is {host}") - else: - click.echo(f"No device with name {alias} found") - return - - if host is None: - click.echo("No host name given, trying discovery..") - await ctx.invoke(discover) - return - else: - if not bulb and not plug and not strip and not lightstrip: - click.echo("No --strip nor --bulb nor --plug given, discovering..") - dev = await Discover.discover_single(host) - elif bulb: - dev = SmartBulb(host) - elif plug: - dev = SmartPlug(host) - elif strip: - dev = SmartStrip(host) - elif lightstrip: - dev = SmartLightStrip(host) - else: - click.echo("Unable to detect type, use --strip or --bulb or --plug!") - return - ctx.obj = dev - - if ctx.invoked_subcommand is None: - await ctx.invoke(state) - - -@cli.group() -@pass_dev -def wifi(dev): - """Commands to control wifi settings.""" - - -@wifi.command() -@pass_dev -async def scan(dev): - """Scan for available wifi networks.""" - click.echo("Scanning for wifi networks, wait a second..") - devs = await dev.wifi_scan() - click.echo(f"Found {len(devs)} wifi networks!") - for dev in devs: - click.echo(f"\t {dev}") - - -@wifi.command() -@click.argument("ssid") -@click.option("--password", prompt=True, hide_input=True) -@click.option("--keytype", default=3) -@pass_dev -async def join(dev: SmartDevice, ssid, password, keytype): - """Join the given wifi network.""" - click.echo(f"Asking the device to connect to {ssid}..") - res = await dev.wifi_join(ssid, password, keytype=keytype) - click.echo( - f"Response: {res} - if the device is not able to join the network, it will revert back to its previous state." - ) - - -@cli.command() -@click.option("--scrub/--no-scrub", default=True) -@click.pass_context -async def dump_discover(ctx, scrub): - """Dump discovery information. - - Useful for dumping into a file to be added to the test suite. - """ - target = ctx.parent.params["target"] - keys_to_scrub = [ - "deviceId", - "fwId", - "hwId", - "oemId", - "mac", - "latitude_i", - "longitude_i", - "latitude", - "longitude", - ] - devs = await Discover.discover(target=target, return_raw=True) - if scrub: - click.echo("Scrubbing personal data before writing") - for dev in devs.values(): - if scrub: - for key in keys_to_scrub: - if key in dev["system"]["get_sysinfo"]: - val = dev["system"]["get_sysinfo"][key] - if key in ["latitude_i", "longitude_i"]: - val = 0 - else: - val = re.sub(r"\w", "0", val) - dev["system"]["get_sysinfo"][key] = val - - model = dev["system"]["get_sysinfo"]["model"] - hw_version = dev["system"]["get_sysinfo"]["hw_ver"] - save_to = f"{model}_{hw_version}.json" - click.echo(f"Saving info to {save_to}") - with open(save_to, "w") as f: - json.dump(dev, f, sort_keys=True, indent=4) - f.write("\n") - - -@cli.command() -@click.option("--timeout", default=3, required=False) -@click.option("--discover-only", default=False) -@click.option("--dump-raw", is_flag=True) -@click.pass_context -async def discover(ctx, timeout, discover_only, dump_raw): - """Discover devices in the network.""" - target = ctx.parent.params["target"] - click.echo(f"Discovering devices for {timeout} seconds") - found_devs = await Discover.discover( - target=target, timeout=timeout, return_raw=dump_raw - ) - if not discover_only: - for ip, dev in found_devs.items(): - await dev.update() - if dump_raw: - click.echo(dev) - continue - ctx.obj = dev - await ctx.invoke(state) - click.echo() - - return found_devs - - -async def find_host_from_alias(alias, target="255.255.255.255", timeout=1, attempts=3): - """Discover a device identified by its alias.""" - click.echo( - f"Trying to discover {alias} using {attempts} attempts of {timeout} seconds" - ) - for attempt in range(1, attempts): - click.echo(f"Attempt {attempt} of {attempts}") - found_devs = await Discover.discover(target=target, timeout=timeout) - found_devs = found_devs.items() - for ip, dev in found_devs: - if dev.alias.lower() == alias.lower(): - host = dev.host - return host - return None - - -@cli.command() -@pass_dev -async def sysinfo(dev): - """Print out full system information.""" - await dev.update() - click.echo(click.style("== System info ==", bold=True)) - click.echo(pf(dev.sys_info)) - - -@cli.command() -@pass_dev -@click.pass_context -async def state(ctx, dev: SmartDevice): - """Print out device state and versions.""" - await dev.update() - click.echo(click.style(f"== {dev.alias} - {dev.model} ==", bold=True)) - click.echo(f"\tHost: {dev.host}") - click.echo( - click.style( - "\tDevice state: {}\n".format("ON" if dev.is_on else "OFF"), - fg="green" if dev.is_on else "red", - ) - ) - if dev.is_strip: - click.echo(click.style("\t== Plugs ==", bold=True)) - for plug in dev.children: # type: ignore - is_on = plug.is_on - alias = plug.alias - click.echo( - click.style( - "\t* Socket '{}' state: {} on_since: {}".format( - alias, ("ON" if is_on else "OFF"), plug.on_since - ), - fg="green" if is_on else "red", - ) - ) - click.echo() - - click.echo(click.style("\t== Generic information ==", bold=True)) - click.echo(f"\tTime: {await dev.get_time()}") - click.echo(f"\tHardware: {dev.hw_info['hw_ver']}") - click.echo(f"\tSoftware: {dev.hw_info['sw_ver']}") - click.echo(f"\tMAC (rssi): {dev.mac} ({dev.rssi})") - click.echo(f"\tLocation: {dev.location}") - - click.echo(click.style("\n\t== Device specific information ==", bold=True)) - for k, v in dev.state_information.items(): - click.echo(f"\t{k}: {v}") - click.echo() - - if dev.has_emeter: - click.echo(click.style("\n\t== Current State ==", bold=True)) - emeter_status = dev.emeter_realtime - click.echo(f"\t{emeter_status}") - - -@cli.command() -@pass_dev -@click.argument("new_alias", required=False, default=None) -@click.option("--index", type=int) -async def alias(dev, new_alias, index): - """Get or set the device (or plug) alias.""" - await dev.update() - if index is not None: - if not dev.is_strip: - click.echo("Index can only used for power strips!") - return - dev = cast(SmartStrip, dev) - dev = dev.get_plug_by_index(index) - - if new_alias is not None: - click.echo(f"Setting alias to {new_alias}") - click.echo(await dev.set_alias(new_alias)) - - click.echo(f"Alias: {dev.alias}") - if dev.is_strip: - for plug in dev.children: - click.echo(f" * {plug.alias}") - - -@cli.command() -@pass_dev -@click.argument("module") -@click.argument("command") -@click.argument("parameters", default=None, required=False) -async def raw_command(dev: SmartDevice, module, command, parameters): - """Run a raw command on the device.""" - import ast - - if parameters is not None: - parameters = ast.literal_eval(parameters) - res = await dev._query_helper(module, command, parameters) - await dev.update() # TODO: is this needed? - click.echo(res) - - -@cli.command() -@pass_dev -@click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False) -@click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False) -@click.option("--erase", is_flag=True) -async def emeter(dev: SmartDevice, year, month, erase): - """Query emeter for historical consumption. - - Daily and monthly data provided in CSV format. - """ - click.echo(click.style("== Emeter ==", bold=True)) - await dev.update() - if not dev.has_emeter: - click.echo("Device has no emeter") - return - - if erase: - click.echo("Erasing emeter statistics..") - click.echo(await dev.erase_emeter_stats()) - return - - if year: - click.echo(f"== For year {year.year} ==") - click.echo("Month, usage (kWh)") - usage_data = await dev.get_emeter_monthly(year.year) - elif month: - click.echo(f"== For month {month.month} of {month.year} ==") - click.echo("Day, usage (kWh)") - usage_data = await dev.get_emeter_daily(year=month.year, month=month.month) - else: - # Call with no argument outputs summary data and returns - usage_data = {} - emeter_status = dev.emeter_realtime - - click.echo("Current: %s A" % emeter_status["current"]) - click.echo("Voltage: %s V" % emeter_status["voltage"]) - click.echo("Power: %s W" % emeter_status["power"]) - click.echo("Total consumption: %s kWh" % emeter_status["total"]) - - click.echo("Today: %s kWh" % dev.emeter_today) - click.echo("This month: %s kWh" % dev.emeter_this_month) - - return - - # output any detailed usage data - for index, usage in usage_data.items(): - click.echo(f"{index}, {usage}") - - -@cli.command() -@click.argument("brightness", type=click.IntRange(0, 100), default=None, required=False) -@click.option("--transition", type=int, required=False) -@pass_dev -async def brightness(dev: SmartBulb, brightness: int, transition: int): - """Get or set brightness.""" - await dev.update() - if not dev.is_dimmable: - click.echo("This device does not support brightness.") - return - if brightness is None: - click.echo(f"Brightness: {dev.brightness}") - else: - click.echo(f"Setting brightness to {brightness}") - click.echo(await dev.set_brightness(brightness, transition=transition)) - - -@cli.command() -@click.argument( - "temperature", type=click.IntRange(2500, 9000), default=None, required=False -) -@click.option("--transition", type=int, required=False) -@pass_dev -async def temperature(dev: SmartBulb, temperature: int, transition: int): - """Get or set color temperature.""" - await dev.update() - if temperature is None: - click.echo(f"Color temperature: {dev.color_temp}") - valid_temperature_range = dev.valid_temperature_range - if valid_temperature_range != (0, 0): - click.echo("(min: {}, max: {})".format(*valid_temperature_range)) - else: - click.echo( - "Temperature range unknown, please open a github issue" - f" or a pull request for model '{dev.model}'" - ) - else: - click.echo(f"Setting color temperature to {temperature}") - await dev.set_color_temp(temperature, transition=transition) - - -@cli.command() -@click.argument("h", type=click.IntRange(0, 360), default=None, required=False) -@click.argument("s", type=click.IntRange(0, 100), default=None, required=False) -@click.argument("v", type=click.IntRange(0, 100), default=None, required=False) -@click.option("--transition", type=int, required=False) -@click.pass_context -@pass_dev -async def hsv(dev, ctx, h, s, v, transition): - """Get or set color in HSV. (Bulb only).""" - await dev.update() - if h is None or s is None or v is None: - click.echo(f"Current HSV: {dev.hsv}") - elif s is None or v is None: - raise click.BadArgumentUsage("Setting a color requires 3 values.", ctx) - else: - click.echo(f"Setting HSV: {h} {s} {v}") - click.echo(await dev.set_hsv(h, s, v, transition=transition)) - - -@cli.command() -@click.argument("state", type=bool, required=False) -@pass_dev -async def led(dev, state): - """Get or set (Plug's) led state.""" - await dev.update() - if state is not None: - click.echo(f"Turning led to {state}") - click.echo(await dev.set_led(state)) - else: - click.echo(f"LED state: {dev.led}") - - -@cli.command() -@pass_dev -async def time(dev): - """Get the device time.""" - click.echo(await dev.get_time()) - - -@cli.command() -@click.option("--index", type=int, required=False) -@click.option("--name", type=str, required=False) -@click.option("--transition", type=int, required=False) -@pass_dev -async def on(dev: SmartDevice, index: int, name: str, transition: int): - """Turn the device on.""" - await dev.update() - if index is not None or name is not None: - if not dev.is_strip: - click.echo("Index and name are only for power strips!") - return - dev = cast(SmartStrip, dev) - if index is not None: - dev = dev.get_plug_by_index(index) - elif name: - dev = dev.get_plug_by_name(name) - - click.echo(f"Turning on {dev.alias}") - await dev.turn_on(transition=transition) - - -@cli.command() -@click.option("--index", type=int, required=False) -@click.option("--name", type=str, required=False) -@click.option("--transition", type=int, required=False) -@pass_dev -async def off(dev: SmartDevice, index: int, name: str, transition: int): - """Turn the device off.""" - await dev.update() - if index is not None or name is not None: - if not dev.is_strip: - click.echo("Index and name are only for power strips!") - return - dev = cast(SmartStrip, dev) - if index is not None: - dev = dev.get_plug_by_index(index) - elif name: - dev = dev.get_plug_by_name(name) - - click.echo(f"Turning off {dev.alias}") - await dev.turn_off(transition=transition) - - -@cli.command() -@click.option("--delay", default=1) -@pass_dev -async def reboot(plug, delay): - """Reboot the device.""" - click.echo("Rebooting the device..") - click.echo(await plug.reboot(delay)) - - -if __name__ == "__main__": - cli() diff --git a/kasa/cli/__init__.py b/kasa/cli/__init__.py new file mode 100644 index 000000000..1d4991659 --- /dev/null +++ b/kasa/cli/__init__.py @@ -0,0 +1 @@ +"""Package for the cli.""" diff --git a/kasa/cli/__main__.py b/kasa/cli/__main__.py new file mode 100644 index 000000000..1cf92da16 --- /dev/null +++ b/kasa/cli/__main__.py @@ -0,0 +1,6 @@ +"""Main module.""" + +from kasa.cli.main import cli + +if __name__ == "__main__": + cli() diff --git a/kasa/cli/common.py b/kasa/cli/common.py new file mode 100644 index 000000000..d0ef9dc30 --- /dev/null +++ b/kasa/cli/common.py @@ -0,0 +1,287 @@ +"""Common cli module.""" + +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 gettext import gettext +from typing import TYPE_CHECKING, Any, Final, NoReturn + +import asyncclick as click + +from kasa import ( + Device, +) + +# Value for optional options if passed without a value +OPTIONAL_VALUE_FLAG: Final = "_FLAG_" + +# Block list of commands which require no update +SKIP_UPDATE_COMMANDS = ["raw-command", "command"] + +pass_dev = click.make_pass_decorator(Device) # type: ignore[type-abstract] + + +try: + from rich import print as _echo +except ImportError: + # Strip out rich formatting if rich is not installed + # but only lower case tags to avoid stripping out + # raw data from the device that is printed from + # the device state. + rich_formatting = re.compile(r"\[/?[a-z]+]") + + def _strip_rich_formatting(echo_func): + """Strip rich formatting from messages.""" + + @wraps(echo_func) + def wrapper(message=None, *args, **kwargs) -> None: + if message is not None: + message = rich_formatting.sub("", message) + echo_func(message, *args, **kwargs) + + return wrapper + + _echo = _strip_rich_formatting(click.echo) + + +def echo(*args, **kwargs) -> None: + """Print a message.""" + ctx = click.get_current_context().find_root() + if "json" not in ctx.params or ctx.params["json"] is False: + _echo(*args, **kwargs) + + +def error(msg: str) -> NoReturn: + """Print an error and exit.""" + echo(f"[bold red]{msg}[/bold red]") + sys.exit(1) + + +def json_formatter_cb(result: Any, **kwargs) -> None: + """Format and output the result as JSON, if requested.""" + 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. + + The singledispatch trick is from hynek: https://hynek.me/articles/serialization/ + """ + return str(val) + + @to_serializable.register(Device) + def _device_to_serializable(val: Device): + """Serialize smart device data, just using the last update raw payload.""" + return val.internal_state + + json_content = json.dumps(result, indent=4, default=to_serializable) + print(json_content) + + +async def invoke_subcommand( + command: click.BaseCommand, + 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 = ( + "Child ID or alias for controlling sub-devices. " + "If no value provided will show an interactive prompt allowing you to " + "select a child." + ) + child_index_help = "Child index controlling sub-devices" + + @contextmanager + def patched_device_update(parent: Device, child: Device): + try: + orig_update = child.update + # patch child update method. Can be removed once update can be called + # directly on child devices + child.update = parent.update # type: ignore[method-assign] + yield child + finally: + child.update = orig_update # type: ignore[method-assign] + + @click.pass_obj + @click.pass_context + @click.option( + "--child", + "--name", + is_flag=False, + flag_value=OPTIONAL_VALUE_FLAG, + default=None, + required=False, + type=click.STRING, + help=child_help, + ) + @click.option( + "--child-index", + "--index", + required=False, + default=None, + type=click.INT, + help=child_index_help, + ) + async def wrapper(ctx: click.Context, dev, *args, child, child_index, **kwargs): + if child := await _get_child_device(dev, child, child_index, ctx.info_name): + ctx.obj = ctx.with_resource(patched_device_update(dev, child)) + dev = child + return await ctx.invoke(wrapped_function, dev, *args, **kwargs) + + # Update wrapper function to look like wrapped function + return update_wrapper(wrapper, wrapped_function) + + +async def _get_child_device( + device: Device, + child_option: str | None, + child_index_option: int | None, + info_command: str | None, +) -> Device | None: + def _list_children(): + return "\n".join( + [ + f"{idx}: {child.device_id} ({child.alias})" + for idx, child in enumerate(device.children) + ] + ) + + if child_option is None and child_index_option is None: + return None + + if info_command in SKIP_UPDATE_COMMANDS: + # The device hasn't had update called (e.g. for cmd_command) + # The way child devices are accessed requires a ChildDevice to + # wrap the communications. Doing this properly would require creating + # a common interfaces for both IOT and SMART child devices. + # As a stop-gap solution, we perform an update instead. + await device.update() + + if not device.children: + error(f"Device: {device.host} does not have children") + + if child_option is not None and child_index_option is not None: + raise click.BadOptionUsage( + "child", "Use either --child or --child-index, not both." + ) + + if child_option is not None: + if child_option is OPTIONAL_VALUE_FLAG: + msg = _list_children() + child_index_option = click.prompt( + f"\n{msg}\nEnter the index number of the child device", + type=click.IntRange(0, len(device.children) - 1), + ) + elif child := device.get_child_device(child_option): + echo(f"Targeting child device {child.alias}") + return child + else: + error( + "No child device found with device_id or name: " + f"{child_option} children are:\n{_list_children()}" + ) + + if TYPE_CHECKING: + assert isinstance(child_index_option, int) + + if child_index_option + 1 > len(device.children) or child_index_option < 0: + error( + f"Invalid index {child_index_option}, " + f"device has {len(device.children)} children" + ) + + child_by_index = device.children[child_index_option] + echo(f"Targeting child device {child_by_index.alias}") + return child_by_index + + +def CatchAllExceptions(cls): + """Capture all exceptions and prints them nicely. + + Idea from https://stackoverflow.com/a/44347763 and + https://stackoverflow.com/questions/52213375 + """ + + def _handle_exception(debug, exc) -> None: + if isinstance(exc, click.ClickException): + raise + # Handle exit request from click. + if isinstance(exc, click.exceptions.Exit): + sys.exit(exc.exit_code) + if isinstance(exc, click.exceptions.Abort): + sys.exit(0) + + echo(f"Raised error: {exc}") + if debug: + raise + echo("Run with --debug enabled to see stacktrace") + sys.exit(1) + + class _CommandCls(cls): + _debug = False + + async def make_context(self, info_name, args, parent=None, **extra): + self._debug = any( + [arg for arg in args if arg in ["--debug", "-d", "--verbose", "-v"]] + ) + try: + return await super().make_context( + info_name, args, parent=parent, **extra + ) + except Exception as exc: + _handle_exception(self._debug, exc) + + async def invoke(self, ctx): + try: + return await super().invoke(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 new file mode 100644 index 000000000..7610a7cdf --- /dev/null +++ b/kasa/cli/device.py @@ -0,0 +1,214 @@ +"""Module for cli device commands.""" + +from __future__ import annotations + +from pprint import pformat as pf +from typing import TYPE_CHECKING + +import asyncclick as click + +from kasa import ( + Device, + Module, +) +from kasa.smart import SmartDevice + +from .common import ( + echo, + error, + pass_dev, + pass_dev_or_child, +) + + +@click.group() +@pass_dev_or_child +def device(dev) -> None: + """Commands to control basic device settings.""" + + +@device.command() +@pass_dev_or_child +@click.pass_context +async def state(ctx, dev: Device): + """Print out device state and versions.""" + from .feature import _echo_all_features + + verbose = ctx.parent.params.get("verbose", False) if ctx.parent else False + + echo(f"[bold]== {dev.alias} - {dev.model} ==[/bold]") + echo(f"Host: {dev.host}") + echo(f"Port: {dev.port}") + echo(f"Device state: {dev.is_on}") + + echo(f"Time: {dev.time} (tz: {dev.timezone})") + 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}") + + echo() + _echo_all_features(dev.features, verbose=verbose) + + if verbose: + echo("\n[bold]== Modules ==[/bold]") + for module in dev.modules.values(): + echo(f"[green]+ {module}[/green]") + + if dev.children: + echo("\n[bold]== Children ==[/bold]") + for child in dev.children: + _echo_all_features( + child.features, + title_prefix=f"{child.alias} ({child.model})", + verbose=verbose, + indent="\t", + ) + if verbose: + echo(f"\n\t[bold]== Child {child.alias} Modules ==[/bold]") + for module in child.modules.values(): + echo(f"\t[green]+ {module}[/green]") + echo() + + if verbose: + echo("\n\t[bold]== Protocol information ==[/bold]") + echo(f"\tCredentials hash: {dev.credentials_hash}") + echo() + from .discover import _echo_discovery_info + + if TYPE_CHECKING: + assert dev._discovery_info + _echo_discovery_info(dev._discovery_info) + + return dev.internal_state + + +@device.command() +@pass_dev_or_child +async def sysinfo(dev): + """Print out full system information.""" + echo("== System info ==") + echo(pf(dev.sys_info)) + return dev.sys_info + + +@device.command() +@click.option("--transition", type=int, required=False) +@pass_dev_or_child +async def on(dev: Device, transition: int): + """Turn the device on.""" + echo(f"Turning on {dev.alias}") + return await dev.turn_on(transition=transition) + + +@device.command +@click.option("--transition", type=int, required=False) +@pass_dev_or_child +async def off(dev: Device, transition: int): + """Turn the device off.""" + echo(f"Turning off {dev.alias}") + return await dev.turn_off(transition=transition) + + +@device.command() +@click.option("--transition", type=int, required=False) +@pass_dev_or_child +async def toggle(dev: Device, transition: int): + """Toggle the device on/off.""" + if dev.is_on: + echo(f"Turning off {dev.alias}") + return await dev.turn_off(transition=transition) + + echo(f"Turning on {dev.alias}") + return await dev.turn_on(transition=transition) + + +@device.command() +@click.argument("state", type=bool, required=False) +@pass_dev_or_child +async def led(dev: Device, state): + """Get or set (Plug's) led state.""" + if not (led := dev.modules.get(Module.Led)): + error("Device does not support led.") + return + if state is not None: + echo(f"Turning led to {state}") + return await led.set_led(state) + else: + echo(f"LED state: {led.led}") + return led.led + + +@device.command() +@click.argument("new_alias", required=False, default=None) +@pass_dev_or_child +async def alias(dev, new_alias): + """Get or set the device (or plug) alias.""" + if new_alias is not None: + echo(f"Setting alias to {new_alias}") + res = await dev.set_alias(new_alias) + await dev.update() + echo(f"Alias set to: {dev.alias}") + return res + + echo(f"Alias: {dev.alias}") + if dev.children: + for plug in dev.children: + echo(f" * {plug.alias}") + + return dev.alias + + +@device.command() +@click.option("--delay", default=1) +@pass_dev +async def reboot(plug, delay): + """Reboot the device.""" + echo("Rebooting the device..") + return await plug.reboot(delay) + + +@device.command() +@pass_dev +async def factory_reset(plug): + """Reset device to factory settings.""" + click.confirm( + "Do you really want to reset the device to factory settings?", abort=True + ) + + return await plug.factory_reset() + + +@device.command() +@pass_dev +@click.option( + "--username", required=True, prompt=True, help="New username to set on the device" +) +@click.option( + "--password", required=True, prompt=True, help="New password to set on the device" +) +async def update_credentials(dev, username, password): + """Update device credentials for authenticated devices.""" + if not isinstance(dev, SmartDevice): + error("Credentials can only be updated on authenticated devices.") + + click.confirm("Do you really want to replace the existing credentials?", abort=True) + + return await dev.update_credentials(username, password) + + +@device.command(name="logs") +@pass_dev_or_child +async def child_logs(dev): + """Print child device trigger logs.""" + if logs := dev.modules.get(Module.TriggerLogs): + await dev.update(update_children=True) + for entry in logs.logs: + print(entry) diff --git a/kasa/cli/discover.py b/kasa/cli/discover.py new file mode 100644 index 000000000..af367e32b --- /dev/null +++ b/kasa/cli/discover.py @@ -0,0 +1,385 @@ +"""Module for cli discovery commands.""" + +from __future__ import annotations + +import asyncio +from pprint import pformat as pf +from typing import TYPE_CHECKING, cast + +import asyncclick as click + +from kasa import ( + AuthenticationError, + Credentials, + Device, + Discover, + UnsupportedDeviceError, +) +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: 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: click.Context) -> DeviceDict: + """Discover devices in the network using udp broadcasts.""" + unsupported = [] + auth_failed = [] + sem = asyncio.Semaphore() + + async def print_unsupported(unsupported_exception: UnsupportedDeviceError) -> None: + unsupported.append(unsupported_exception) + async with sem: + if unsupported_exception.discovery_result: + echo("== Unsupported device ==") + _echo_discovery_info(unsupported_exception.discovery_result) + echo() + else: + echo("== Unsupported device ==") + echo(f"\t{unsupported_exception}") + echo() + + 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) + echo() + else: + ctx.parent.obj = dev + await ctx.parent.invoke(state) + echo() + + 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") + if unsupported: + echo(f"Found {len(unsupported)} unsupported devices") + if auth_failed: + echo(f"Found {len(auth_failed)} devices that failed to authenticate") + + 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: 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} {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}") + + async def print_unsupported(unsupported_exception: UnsupportedDeviceError): + if host := unsupported_exception.host: + echo(f"{host:<15} UNSUPPORTED DEVICE") + + 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: 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"] + discovery_timeout = params["discovery_timeout"] + timeout = params["timeout"] + host = params["host"] + port = params["port"] + + 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") + 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( + target=target, + discovery_timeout=discovery_timeout, + on_discovered=print_discovered, + on_unsupported=print_unsupported, + port=port, + timeout=timeout, + credentials=credentials, + on_discovered_raw=print_raw, + ) + + return discovered_devices + + +@discover.command() +@click.pass_context +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.find_root().params + username = params["username"] + password = params["password"] + timeout = params["timeout"] + host = params["host"] + port = params["port"] + + if not host: + error("--host option must be supplied to discover config") + + credentials = Credentials(username, password) if username and password else None + + host_port = host + (f":{port}" if port else "") + + def on_attempt(connect_attempt: ConnectAttempt, success: bool) -> None: + 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) + + dev = await Discover.try_connect_all( + host, credentials=credentials, timeout=timeout, port=port, on_attempt=on_attempt + ) + if dev: + cparams = dev.config.connection_type + echo("Managed to connect, cli options to connect are:") + echo( + f"--device-family {cparams.device_family.value} " + 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}") + + +def _echo_dictionary(discovery_info: dict) -> None: + echo("\t[bold]== Discovery information ==[/bold]") + for key, value in discovery_info.items(): + key_name = " ".join(x.capitalize() or "_" for x in key.split("_")) + key_name_and_spaces = "{:<15}".format(key_name + ":") + echo(f"\t{key_name_and_spaces}{value}") + + +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 sysinfo := _extract_sys_info(discovery_info): + _echo_dictionary(sysinfo) + return + + try: + dr = DiscoveryResult.from_dict(discovery_info) + except Exception: + _echo_dictionary(discovery_info) + return + + def _conditional_echo(label, value): + if value: + ws = " " * (19 - len(label)) + echo(f"\t{label}:{ws}{value}") + + echo("\t[bold]== Discovery Result ==[/bold]") + _conditional_echo("Device Type", dr.device_type) + _conditional_echo("Device Model", dr.device_model) + _conditional_echo("Device Name", dr.device_name) + _conditional_echo("IP", dr.ip) + _conditional_echo("MAC", dr.mac) + _conditional_echo("Device Id (hash)", dr.device_id) + _conditional_echo("Owner (hash)", dr.owner) + _conditional_echo("FW Ver", dr.firmware_version) + _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.obd_src) + _conditional_echo("Factory Default", dr.factory_default) + _conditional_echo("Encrypt Type", dr.encrypt_type) + 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) + + +async def find_dev_from_alias( + alias: str, + credentials: Credentials | None, + target: str = "255.255.255.255", + timeout: int = 5, + attempts: int = 3, +) -> Device | None: + """Discover a device identified by its alias.""" + found_event = asyncio.Event() + found_device = [] + seen_hosts = set() + + async def on_discovered(dev: Device): + if dev.host in seen_hosts: + return + seen_hosts.add(dev.host) + try: + await dev.update() + except Exception as ex: + echo(f"Error querying device {dev.host}: {ex}") + return + finally: + await dev.protocol.close() + if not dev.alias: + echo(f"Skipping device {dev.host} with no alias") + return + if dev.alias.lower() == alias.lower(): + found_device.append(dev) + found_event.set() + + async def do_discover(): + for _ in range(1, attempts): + await Discover.discover( + target=target, + timeout=timeout, + credentials=credentials, + on_discovered=on_discovered, + ) + if found_event.is_set(): + break + found_event.set() + + asyncio.create_task(do_discover()) + await found_event.wait() + return found_device[0] if found_device else None diff --git a/kasa/cli/feature.py b/kasa/cli/feature.py new file mode 100644 index 000000000..a4c739f6b --- /dev/null +++ b/kasa/cli/feature.py @@ -0,0 +1,154 @@ +"""Module for cli feature commands.""" + +from __future__ import annotations + +import ast + +import asyncclick as click + +from kasa import Device, Feature + +from .common import ( + echo, + error, + pass_dev_or_child, +) + + +def _echo_features( + features: dict[str, Feature], + title: str, + category: Feature.Category | None = None, + verbose: bool = False, + indent: str = "\t", +) -> None: + """Print out a listing of features and their values.""" + if category is not None: + features = { + id_: feat for id_, feat in features.items() if feat.category == category + } + + echo(f"{indent}[bold]{title}[/bold]") + for _, feat in features.items(): + try: + echo(f"{indent}{feat}") + if verbose: + echo(f"{indent}\tType: {feat.type}") + echo(f"{indent}\tCategory: {feat.category}") + echo(f"{indent}\tIcon: {feat.icon}") + except Exception as ex: + echo(f"{indent}{feat.name} ({feat.id}): [red]got exception ({ex})[/red]") + + +def _echo_all_features( + features, *, verbose=False, title_prefix=None, indent="" +) -> None: + """Print out all features by category.""" + if title_prefix is not None: + echo(f"[bold]\n{indent}== {title_prefix} ==[/bold]") + echo() + _echo_features( + features, + title="== Primary features ==", + category=Feature.Category.Primary, + verbose=verbose, + indent=indent, + ) + echo() + _echo_features( + features, + title="== Information ==", + category=Feature.Category.Info, + verbose=verbose, + indent=indent, + ) + echo() + _echo_features( + features, + title="== Configuration ==", + category=Feature.Category.Config, + verbose=verbose, + indent=indent, + ) + echo() + _echo_features( + features, + title="== Debug ==", + category=Feature.Category.Debug, + verbose=verbose, + indent=indent, + ) + + +@click.command(name="feature") +@click.argument("name", required=False) +@click.argument("value", required=False) +@pass_dev_or_child +@click.pass_context +async def feature( + ctx: click.Context, + dev: Device, + name: str, + value, +): + """Access and modify features. + + If no *name* is given, lists available features and their values. + If only *name* is given, the value of named feature is returned. + If both *name* and *value* are set, the described setting is changed. + """ + verbose = ctx.parent.params.get("verbose", False) if ctx.parent else False + + if not name: + _echo_all_features(dev.features, verbose=verbose, indent="") + + if dev.children: + for child_dev in dev.children: + _echo_all_features( + child_dev.features, + verbose=verbose, + title_prefix=f"Child {child_dev.alias}", + indent="\t", + ) + + return + + if name not in dev.features: + error(f"No feature by name '{name}'") + return + + feat = dev.features[name] + + if value is None and feat.type is Feature.Type.Action: + echo(f"Executing action {name}") + response = await dev.features[name].set_value(value) + echo(response) + return response + + if value is None: + unit = f" {feat.unit}" if feat.unit else "" + echo(f"{feat.name} ({name}): {feat.value}{unit}") + return feat.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() + echo(f"New state: {feat.value}") + + return response 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 new file mode 100644 index 000000000..0e9435db2 --- /dev/null +++ b/kasa/cli/lazygroup.py @@ -0,0 +1,71 @@ +"""Module for lazily instantiating sub modules. + +Taken from the click help files. +""" + +from __future__ import annotations + +import importlib + +import asyncclick as click + + +class LazyGroup(click.Group): + """Lazy group class.""" + + def __init__(self, *args, lazy_subcommands=None, **kwargs) -> None: + super().__init__(*args, **kwargs) + # lazy_subcommands is a map of the form: + # + # {command-name} -> {module-name}.{command-object-name} + # + self.lazy_subcommands = lazy_subcommands or {} + + def list_commands(self, ctx): + """List click commands.""" + base = super().list_commands(ctx) + lazy = list(self.lazy_subcommands.keys()) + return lazy + base + + def get_command(self, ctx, cmd_name): + """Get click command.""" + if cmd_name in self.lazy_subcommands: + return self._lazy_load(cmd_name) + return super().get_command(ctx, cmd_name) + + def format_commands(self, ctx, formatter) -> None: + """Format the top level help output.""" + sections: dict[str, list] = {} + for cmd, parent in self.lazy_subcommands.items(): + sections.setdefault(parent, []) + cmd_obj = self.get_command(ctx, cmd) + help = cmd_obj.get_short_help_str() + sections[parent].append((cmd, help)) + for section in sections: + if section: + header = ( + f"Common {section} commands (also available " + f"under the `{section}` subcommand)" + ) + else: + header = "Subcommands" + with formatter.section(header): + formatter.write_dl(sections[section]) + + def _lazy_load(self, cmd_name): + # lazily loading a command, first get the module name and attribute name + if not (import_path := self.lazy_subcommands[cmd_name]): + import_path = f".{cmd_name}.{cmd_name}" + else: + import_path = f".{import_path}.{cmd_name}" + modname, cmd_object_name = import_path.rsplit(".", 1) + # do the import + mod = importlib.import_module(modname, package=__package__) + # get the Command object from that module + cmd_object = getattr(mod, cmd_object_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" + ) + return cmd_object diff --git a/kasa/cli/light.py b/kasa/cli/light.py new file mode 100644 index 000000000..a77855633 --- /dev/null +++ b/kasa/cli/light.py @@ -0,0 +1,221 @@ +"""Module for cli light control commands.""" + +import asyncclick as click + +from kasa import ( + Device, + Module, +) +from kasa.iot import ( + IotBulb, +) + +from .common import echo, error, pass_dev_or_child + + +@click.group() +@pass_dev_or_child +def light(dev) -> None: + """Commands to control light settings.""" + + +@light.command() +@click.argument("brightness", type=click.IntRange(0, 100), default=None, required=False) +@click.option("--transition", type=int, required=False) +@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.has_feature( + "brightness" + ): + error("This device does not support brightness.") + return + + if brightness is None: + echo(f"Brightness: {light.brightness}") + return light.brightness + else: + echo(f"Setting brightness to {brightness}") + return await light.set_brightness(brightness, transition=transition) + + +@light.command() +@click.argument( + "temperature", type=click.IntRange(2500, 9000), default=None, required=False +) +@click.option("--transition", type=int, required=False) +@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 ( + 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 = color_temp_feat.range + if valid_temperature_range != (0, 0): + echo("(min: {}, max: {})".format(*valid_temperature_range)) + else: + echo( + "Temperature range unknown, please open a github issue" + f" or a pull request for model '{dev.model}'" + ) + return color_temp_feat.range + else: + echo(f"Setting color temperature to {temperature}") + return await light.set_color_temp(temperature, transition=transition) + + +@light.command() +@click.argument("effect", type=click.STRING, default=None, required=False) +@click.pass_context +@pass_dev_or_child +async def effect(dev: Device, ctx, effect): + """Set an effect.""" + if not (light_effect := dev.modules.get(Module.LightEffect)): + error("Device does not support effects") + return + if effect is None: + echo( + f"Light effect: {light_effect.effect}\n" + + f"Available Effects: {light_effect.effect_list}" + ) + return light_effect.effect + + if effect not in light_effect.effect_list: + raise click.BadArgumentUsage( + f"Effect must be one of: {light_effect.effect_list}", ctx + ) + + echo(f"Setting Effect: {effect}") + return await light_effect.set_effect(effect) + + +@light.command() +@click.argument("h", type=click.IntRange(0, 360), default=None, required=False) +@click.argument("s", type=click.IntRange(0, 100), default=None, required=False) +@click.argument("v", type=click.IntRange(0, 100), default=None, required=False) +@click.option("--transition", type=int, required=False) +@click.pass_context +@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.has_feature("hsv"): + error("Device does not support colors") + return + + if h is None and s is None and v is None: + echo(f"Current HSV: {light.hsv}") + return light.hsv + elif s is None or v is None: + raise click.BadArgumentUsage("Setting a color requires 3 values.", ctx) + else: + echo(f"Setting HSV: {h} {s} {v}") + return await light.set_hsv(h, s, v, transition=transition) + + +@light.group(invoke_without_command=True) +@pass_dev_or_child +@click.pass_context +async def presets(ctx, dev): + """List and modify bulb setting presets.""" + if ctx.invoked_subcommand is None: + return await ctx.invoke(presets_list) + + +@presets.command(name="list") +@pass_dev_or_child +def presets_list(dev: Device): + """List presets.""" + if not (light_preset := dev.modules.get(Module.LightPreset)): + error("Device does not support light presets") + return + + for idx, preset in enumerate(light_preset.preset_states_list): + echo( + f"[{idx}] Hue: {preset.hue or '':3} " + f"Saturation: {preset.saturation or '':3} " + f"Brightness/Value: {preset.brightness or '':3} " + f"Temp: {preset.color_temp or '':4}" + ) + + return light_preset.preset_states_list + + +@presets.command(name="modify") +@click.argument("index", type=int) +@click.option("--brightness", type=int, required=False, default=None) +@click.option("--hue", type=int, required=False, default=None) +@click.option("--saturation", type=int, required=False, default=None) +@click.option("--temperature", type=int, required=False, default=None) +@pass_dev_or_child +async def presets_modify(dev: Device, index, brightness, hue, saturation, temperature): + """Modify a preset.""" + if not (light_preset := dev.modules.get(Module.LightPreset)): + error("Device does not support light presets") + return + + max_index = len(light_preset.preset_states_list) - 1 + if index > len(light_preset.preset_states_list) - 1: + error(f"Invalid index, must be between 0 and {max_index}") + return + + if all([val is None for val in {brightness, hue, saturation, temperature}]): + error("Need to supply at least one option to modify.") + return + + # Preset names have `Not set`` as the first value + preset_name = light_preset.preset_list[index + 1] + preset = light_preset.preset_states_list[index] + + echo(f"Preset {preset_name} currently: {preset}") + + if brightness is not None and preset.brightness is not None: + preset.brightness = brightness + if hue is not None and preset.hue is not None: + preset.hue = hue + if saturation is not None and preset.saturation is not None: + preset.saturation = saturation + if temperature is not None and preset.temperature is not None: + preset.color_temp = temperature + + echo(f"Updating preset {preset_name} to: {preset}") + + return await light_preset.save_preset(preset_name, preset) + + +@light.command() +@pass_dev_or_child +@click.option("--type", type=click.Choice(["soft", "hard"], case_sensitive=False)) +@click.option("--last", is_flag=True) +@click.option("--preset", type=int) +async def turn_on_behavior(dev: Device, type, last, preset): + """Modify bulb turn-on behavior.""" + if dev.device_type is not Device.Type.Bulb or not isinstance(dev, IotBulb): + error("Presets only supported on iot bulbs") + return + settings = await dev.get_turn_on_behavior() + echo(f"Current turn on behavior: {settings}") + + # Return if we are not setting the value + if not type and not last and not preset: + return settings + + # If we are setting the value, the type has to be specified + if (last or preset) and type is None: + echo("To set the behavior, you need to define --type") + return + + behavior = getattr(settings, type) + + if last: + echo(f"Going to set {type} to last") + behavior.preset = None + elif preset is not None: + echo(f"Going to set {type} to preset {preset}") + behavior.preset = preset + + return await dev.set_turn_on_behavior(settings) diff --git a/kasa/cli/main.py b/kasa/cli/main.py new file mode 100755 index 000000000..15ad211bd --- /dev/null +++ b/kasa/cli/main.py @@ -0,0 +1,442 @@ +"""Main module for cli tool.""" + +from __future__ import annotations + +import ast +import asyncio +import json +import logging +import sys +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING, Any + +import asyncclick as click + +if TYPE_CHECKING: + from kasa import Device + +from kasa.deviceconfig import DeviceEncryptionType + +from .common import ( + SKIP_UPDATE_COMMANDS, + CatchAllExceptions, + echo, + error, + invoke_subcommand, + json_formatter_cb, + pass_dev_or_child, +) +from .lazygroup import LazyGroup + +TYPES = [ + "plug", + "switch", + "bulb", + "dimmer", + "strip", + "lightstrip", + "smart", + "camera", +] + +ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in DeviceEncryptionType] +DEFAULT_TARGET = "255.255.255.255" + + +def _legacy_type_to_class(_type: str) -> Any: + from kasa.iot import ( + IotBulb, + IotDimmer, + IotLightStrip, + IotPlug, + IotStrip, + IotWallSwitch, + ) + + TYPE_TO_CLASS = { + "plug": IotPlug, + "switch": IotWallSwitch, + "bulb": IotBulb, + "dimmer": IotDimmer, + "strip": IotStrip, + "lightstrip": IotLightStrip, + } + return TYPE_TO_CLASS[_type] + + +@click.group( + invoke_without_command=True, + cls=CatchAllExceptions(LazyGroup), + lazy_subcommands={ + "discover": None, + "device": None, + "feature": None, + "light": None, + "wifi": None, + "time": None, + "schedule": None, + "usage": None, + "energy": "usage", + # device commands runnnable at top level + "state": "device", + "on": "device", + "off": "device", + "toggle": "device", + "led": "device", + "alias": "device", + "reboot": "device", + "update_credentials": "device", + "sysinfo": "device", + # light commands runnnable at top level + "presets": "light", + "brightness": "light", + "hsv": "light", + "temperature": "light", + "effect": "light", + "vacuum": "vacuum", + "hub": "hub", + }, + result_callback=json_formatter_cb, +) +@click.option( + "--host", + envvar="KASA_HOST", + required=False, + help="The host name or IP address of the device to connect to.", +) +@click.option( + "--port", + envvar="KASA_PORT", + required=False, + type=int, + help="The port of the device to connect to.", +) +@click.option( + "--alias", + envvar="KASA_NAME", + required=False, + help="The device name, or alias, of the device to connect to.", +) +@click.option( + "--target", + envvar="KASA_TARGET", + default=DEFAULT_TARGET, + required=False, + show_default=True, + help="The broadcast address to be used for discovery.", +) +@click.option( + "-v", + "--verbose", + envvar="KASA_VERBOSE", + required=False, + default=False, + is_flag=True, + help="Be more verbose on output", +) +@click.option( + "-d", + "--debug", + envvar="KASA_DEBUG", + default=False, + is_flag=True, + help="Print debug output", +) +@click.option( + "--type", + envvar="KASA_TYPE", + default=None, + type=click.Choice(TYPES, case_sensitive=False), + help="The device type in order to bypass discovery. Use `smart` for newer devices", +) +@click.option( + "--json/--no-json", + envvar="KASA_JSON", + default=False, + is_flag=True, + help="Output raw device response as JSON.", +) +@click.option( + "-e", + "--encrypt-type", + envvar="KASA_ENCRYPT_TYPE", + default=None, + type=click.Choice(ENCRYPT_TYPES, case_sensitive=False), +) +@click.option( + "-df", + "--device-family", + envvar="KASA_DEVICE_FAMILY", + default="SMART.TAPOPLUG", + help="Device family type, e.g. `SMART.KASASWITCH`. Deprecated use `--type smart`", +) +@click.option( + "-lv", + "--login-version", + envvar="KASA_LOGIN_VERSION", + default=2, + type=int, + help="The login version for device authentication. Defaults to 2", +) +@click.option( + "--https/--no-https", + envvar="KASA_HTTPS", + default=False, + is_flag=True, + type=bool, + help="Set flag if the device encryption uses https.", +) +@click.option( + "--timeout", + envvar="KASA_TIMEOUT", + default=5, + required=False, + show_default=True, + help="Timeout for device communications.", +) +@click.option( + "--discovery-timeout", + envvar="KASA_DISCOVERY_TIMEOUT", + default=10, + required=False, + show_default=True, + help="Timeout for discovery.", +) +@click.option( + "--username", + default=None, + required=False, + envvar="KASA_USERNAME", + help="Username/email address to authenticate to device.", +) +@click.option( + "--password", + default=None, + required=False, + envvar="KASA_PASSWORD", + help="Password to use to authenticate to device.", +) +@click.option( + "--credentials-hash", + default=None, + required=False, + envvar="KASA_CREDENTIALS_HASH", + help="Hashed credentials used to authenticate to the device.", +) +@click.version_option(package_name="python-kasa") +@click.pass_context +async def cli( + ctx, + host, + port, + alias, + target, + verbose, + debug, + type, + encrypt_type, + https, + device_family, + login_version, + json, + timeout, + discovery_timeout, + username, + password, + credentials_hash, +): + """A tool for controlling TP-Link smart home devices.""" # noqa + # no need to perform any checks if we are just displaying the help + if "--help" in sys.argv: + # Context object is required to avoid crashing on sub-groups + ctx.obj = object() + return + + if target != DEFAULT_TARGET and host: + error("--target is not a valid option for single host discovery") + + logging_config: dict[str, Any] = { + "level": logging.DEBUG if debug > 0 else logging.INFO + } + try: + from rich.logging import RichHandler + + rich_config = { + "show_time": False, + } + logging_config["handlers"] = [RichHandler(**rich_config)] + logging_config["format"] = "%(message)s" + except ImportError: + pass + + # The configuration should be converted to use dictConfig, + # but this keeps mypy happy for now + logging.basicConfig(**logging_config) # type: ignore + + if ctx.invoked_subcommand == "discover": + return + + if alias is not None and host is not None: + raise click.BadOptionUsage("alias", "Use either --alias or --host, not both.") + + if bool(password) != bool(username): + raise click.BadOptionUsage( + "username", "Using authentication requires both --username and --password" + ) + + if username: + from kasa.credentials import Credentials + + credentials = Credentials(username=username, password=password) + else: + credentials = None + + if host is None and alias is None: + if ctx.invoked_subcommand and ctx.invoked_subcommand != "discover": + error("Only discover is available without --host or --alias") + + echo("No host name given, trying discovery..") + from .discover import 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 + + config = DeviceConfig(host=host, port_override=port, timeout=timeout) + dev = _legacy_type_to_class(type)(host, config=config) + elif type in {"smart", "camera"} or (device_family and encrypt_type): + if type == "camera": + encrypt_type = "AES" + https = True + device_family = "SMART.IPCAMERA" + + from kasa.device import Device + from kasa.deviceconfig import ( + DeviceConfig, + DeviceConnectionParameters, + DeviceEncryptionType, + DeviceFamily, + ) + + if not encrypt_type: + encrypt_type = "KLAP" + + ctype = DeviceConnectionParameters( + DeviceFamily(device_family), + DeviceEncryptionType(encrypt_type), + login_version, + https, + ) + config = DeviceConfig( + host=host, + port_override=port, + credentials=credentials, + credentials_hash=credentials_hash, + timeout=timeout, + connection_type=ctype, + ) + dev = await Device.connect(config=config) + device_updated = True + elif alias: + echo(f"Alias is given, using discovery to find host {alias}") + + from .discover import find_dev_from_alias + + dev = await find_dev_from_alias( + alias=alias, target=target, credentials=credentials + ) + if not dev: + echo(f"No device with name {alias} found") + return + echo(f"Found hostname by alias: {dev.host}") + device_updated = True + else: # host will be set + from .discover import discover + + 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. + if ctx.invoked_subcommand not in SKIP_UPDATE_COMMANDS and not device_updated: + await dev.update() + + @asynccontextmanager + async def async_wrapped_device(device: Device): + try: + yield device + finally: + await device.disconnect() + + ctx.obj = await ctx.with_async_resource(async_wrapped_device(dev)) + + # 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 +async def shell(dev: Device) -> None: + """Open interactive shell.""" + echo(f"Opening shell for {dev}") + from ptpython.repl import embed + + logging.getLogger("parso").setLevel(logging.WARNING) # prompt parsing + logging.getLogger("asyncio").setLevel(logging.WARNING) + loop = asyncio.get_event_loop() + try: + await embed( # type: ignore[func-returns-value] + globals=globals(), + locals=locals(), + return_asyncio_coroutine=True, + patch_stdout=True, + ) + except EOFError: + loop.stop() + + +@cli.command() +@click.pass_context +@click.argument("module") +@click.argument("command") +@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) + return await ctx.forward(cmd_command) + + +@cli.command(name="command") +@click.option("--module", required=False, help="Module for IOT protocol.") +@click.argument("command") +@click.argument("parameters", default=None, required=False) +@pass_dev_or_child +async def cmd_command(dev: Device, module, command, parameters): + """Run a raw command on the device.""" + if parameters is not None: + parameters = ast.literal_eval(parameters) + + from kasa import KasaException + from kasa.iot import IotDevice + from kasa.smart import SmartDevice + + if isinstance(dev, IotDevice): + res = await dev._query_helper(module, command, parameters) + elif isinstance(dev, SmartDevice): + res = await dev._query_helper(command, parameters) + else: + raise KasaException("Unexpected device type %s.", dev) + echo(json.dumps(res)) + return res diff --git a/kasa/cli/schedule.py b/kasa/cli/schedule.py new file mode 100644 index 000000000..7c9c73817 --- /dev/null +++ b/kasa/cli/schedule.py @@ -0,0 +1,46 @@ +"""Module for cli schedule commands..""" + +from __future__ import annotations + +import asyncclick as click + +from .common import ( + echo, + error, + pass_dev, + pass_dev_or_child, +) + + +@click.group() +@pass_dev +async def schedule(dev) -> None: + """Scheduling commands.""" + + +@schedule.command(name="list") +@pass_dev_or_child +@click.argument("type", default="schedule") +async def _schedule_list(dev, type): + """Return the list of schedule actions for the given type.""" + sched = dev.modules[type] + for rule in sched.rules: + print(rule) + else: + error(f"No rules of type {type}") + + return sched.rules + + +@schedule.command(name="delete") +@pass_dev_or_child +@click.option("--id", type=str, required=True) +async def delete_rule(dev, id): + """Delete rule from device.""" + schedule = dev.modules["schedule"] + rule_to_delete = next(filter(lambda rule: (rule.id == id), schedule.rules), None) + if rule_to_delete: + echo(f"Deleting rule id {id}") + return await schedule.delete_rule(rule_to_delete) + else: + error(f"No rule with id {id} was found") diff --git a/kasa/cli/time.py b/kasa/cli/time.py new file mode 100644 index 000000000..e2cb4c16c --- /dev/null +++ b/kasa/cli/time.py @@ -0,0 +1,160 @@ +"""Module for cli time commands..""" + +from __future__ import annotations + +import zoneinfo +from datetime import datetime + +import asyncclick as click + +from kasa import ( + Device, + Module, +) +from kasa.iot import IotDevice +from kasa.iot.iottimezone import get_matching_timezones + +from .common import ( + echo, + error, + pass_dev, +) + + +@click.group(invoke_without_command=True) +@click.pass_context +async def time(ctx: click.Context) -> None: + """Get and set time.""" + if ctx.invoked_subcommand is None: + await ctx.invoke(time_get) + + +@time.command(name="get") +@pass_dev +async def time_get(dev: Device): + """Get the device time.""" + res = dev.time + echo(f"Current time: {dev.time} ({dev.timezone})") + return res + + +@time.command(name="sync") +@click.option( + "--timezone", + type=str, + required=False, + default=None, + help="IANA timezone name, will use current device timezone if not provided.", +) +@click.option( + "--skip-confirm", + type=str, + required=False, + default=False, + is_flag=True, + help="Do not ask to confirm the timezone if an exact match is not found.", +) +@pass_dev +async def time_sync(dev: Device, timezone: str | None, skip_confirm: bool): + """Set the device time to current time.""" + if (time := dev.modules.get(Module.Time)) is None: + echo("Device does not have time module") + return + + now = datetime.now() + + tzinfo: zoneinfo.ZoneInfo | None = None + if timezone: + tzinfo = await _get_timezone(dev, timezone, skip_confirm) + if tzinfo.utcoffset(now) != now.astimezone().utcoffset(): + error( + f"{timezone} has a different utc offset to local time," + + "syncing will produce unexpected results." + ) + now = now.replace(tzinfo=tzinfo) + + echo(f"Old time: {time.time} ({time.timezone})") + + await time.set_time(now) + + await dev.update() + echo(f"New time: {time.time} ({time.timezone})") + + +@time.command(name="set") +@click.argument("year", type=int) +@click.argument("month", type=int) +@click.argument("day", type=int) +@click.argument("hour", type=int) +@click.argument("minute", type=int) +@click.argument("seconds", type=int, required=False, default=0) +@click.option( + "--timezone", + type=str, + required=False, + default=None, + help="IANA timezone name, will use current device timezone if not provided.", +) +@click.option( + "--skip-confirm", + type=bool, + required=False, + default=False, + is_flag=True, + help="Do not ask to confirm the timezone if an exact match is not found.", +) +@pass_dev +async def time_set( + dev: Device, + year: int, + month: int, + day: int, + hour: int, + minute: int, + seconds: int, + timezone: str | None, + skip_confirm: bool, +): + """Set the device time to the provided time.""" + if (time := dev.modules.get(Module.Time)) is None: + echo("Device does not have time module") + return + + tzinfo: zoneinfo.ZoneInfo | None = None + if timezone: + tzinfo = await _get_timezone(dev, timezone, skip_confirm) + + echo(f"Old time: {time.time} ({time.timezone})") + + await time.set_time(datetime(year, month, day, hour, minute, seconds, 0, tzinfo)) + + await dev.update() + echo(f"New time: {time.time} ({time.timezone})") + + +async def _get_timezone(dev, timezone, skip_confirm) -> zoneinfo.ZoneInfo: + """Get the tzinfo from the timezone or return none.""" + tzinfo: zoneinfo.ZoneInfo | None = None + + if timezone not in zoneinfo.available_timezones(): + error(f"{timezone} is not a valid IANA timezone.") + + tzinfo = zoneinfo.ZoneInfo(timezone) + if skip_confirm is False and isinstance(dev, IotDevice): + matches = await get_matching_timezones(tzinfo) + if not matches: + error(f"Device cannot support {timezone} timezone.") + first = matches[0] + msg = ( + f"An exact match for {timezone} could not be found, " + + f"timezone will be set to {first}" + ) + if len(matches) == 1: + click.confirm(msg, abort=True) + else: + msg = ( + f"Supported timezones matching {timezone} are {', '.join(matches)}\n" + + msg + ) + click.confirm(msg, abort=True) + return tzinfo diff --git a/kasa/cli/usage.py b/kasa/cli/usage.py new file mode 100644 index 000000000..c383f7697 --- /dev/null +++ b/kasa/cli/usage.py @@ -0,0 +1,113 @@ +"""Module for cli usage commands..""" + +from __future__ import annotations + +from typing import cast + +import asyncclick as click + +from kasa import ( + Device, + Module, +) +from kasa.interfaces import Energy +from kasa.iot.modules import Usage + +from .common import ( + echo, + error, + pass_dev_or_child, +) + + +@click.command() +@click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False) +@click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False) +@click.option("--erase", is_flag=True) +@pass_dev_or_child +async def energy(dev: Device, year, month, erase): + """Query energy module for historical consumption. + + Daily and monthly data provided in CSV format. + """ + echo("[bold]== Energy ==[/bold]") + if not (energy := dev.modules.get(Module.Energy)): + error("Device has no energy module.") + return + + if (year or month or erase) and not energy.supports( + Energy.ModuleFeature.PERIODIC_STATS + ): + error("Device does not support historical statistics") + return + + if erase: + echo("Erasing emeter statistics..") + return await energy.erase_stats() + + if year: + echo(f"== For year {year.year} ==") + echo("Month, usage (kWh)") + usage_data = await energy.get_monthly_stats(year=year.year) + elif month: + echo(f"== For month {month.month} of {month.year} ==") + echo("Day, usage (kWh)") + usage_data = await energy.get_daily_stats(year=month.year, month=month.month) + else: + # Call with no argument outputs summary data and returns + emeter_status = energy.status + + echo("Current: {} A".format(emeter_status["current"])) + echo("Voltage: {} V".format(emeter_status["voltage"])) + echo("Power: {} W".format(emeter_status["power"])) + echo("Total consumption: {} kWh".format(emeter_status["total"])) + + echo(f"Today: {energy.consumption_today} kWh") + echo(f"This month: {energy.consumption_this_month} kWh") + + return emeter_status + + # output any detailed usage data + for index, usage in usage_data.items(): + echo(f"{index}, {usage}") + + return usage_data + + +@click.command() +@click.option("--year", type=click.DateTime(["%Y"]), default=None, required=False) +@click.option("--month", type=click.DateTime(["%Y-%m"]), default=None, required=False) +@click.option("--erase", is_flag=True) +@pass_dev_or_child +async def usage(dev: Device, year, month, erase): + """Query usage for historical consumption. + + Daily and monthly data provided in CSV format. + """ + echo("[bold]== Usage ==[/bold]") + usage = cast(Usage, dev.modules["usage"]) + + if erase: + echo("Erasing usage statistics..") + return await usage.erase_stats() + + if year: + echo(f"== For year {year.year} ==") + echo("Month, usage (minutes)") + usage_data = await usage.get_monthstat(year=year.year) + elif month: + echo(f"== For month {month.month} of {month.year} ==") + echo("Day, usage (minutes)") + usage_data = await usage.get_daystat(year=month.year, month=month.month) + else: + # Call with no argument outputs summary data and returns + echo(f"Today: {usage.usage_today} minutes") + echo(f"This month: {usage.usage_this_month} minutes") + + return usage + + # output any detailed usage data + for index, usage in usage_data.items(): + echo(f"{index}, {usage}") + + return usage_data 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 new file mode 100644 index 000000000..0fc7bdd62 --- /dev/null +++ b/kasa/cli/wifi.py @@ -0,0 +1,60 @@ +"""Module for cli wifi commands.""" + +from __future__ import annotations + +import asyncclick as click + +from kasa import ( + Device, + KasaException, +) + +from .common import ( + echo, + pass_dev, +) + + +@click.group() +def wifi() -> None: + """Commands to control wifi settings.""" + + +@wifi.command() +@pass_dev +async def scan(dev): + """Scan for available wifi networks.""" + echo("Scanning for wifi networks, wait a second..") + devs = await dev.wifi_scan() + echo(f"Found {len(devs)} wifi networks!") + for dev in devs: + echo(f"\t {dev}") + + return devs + + +@wifi.command() +@click.argument("ssid") +@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}..") + 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." + ) + + return res diff --git a/kasa/credentials.py b/kasa/credentials.py new file mode 100644 index 000000000..3497b76aa --- /dev/null +++ b/kasa/credentials.py @@ -0,0 +1,32 @@ +"""Credentials class for username / passwords.""" + +from __future__ import annotations + +import base64 +from dataclasses import dataclass, field + + +@dataclass +class Credentials: + """Credentials for authentication.""" + + #: Username (email address) of the cloud account + username: str = field(default="", repr=False) + #: Password of the cloud account + password: str = field(default="", repr=False) + + +def get_default_credentials(crdentials: tuple[str, str]) -> Credentials: + """Return decoded default credentials.""" + 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 new file mode 100644 index 000000000..efd74c135 --- /dev/null +++ b/kasa/device.py @@ -0,0 +1,643 @@ +"""Interact with TPLink Smart Home devices. + +Once you have a device via :ref:`Discovery ` or +:ref:`Connect ` you can start interacting with a device. + +>>> from kasa import Discover +>>> +>>> dev = await Discover.discover_single( +>>> "127.0.0.2", +>>> username="user@example.com", +>>> password="great_password" +>>> ) +>>> + +Most devices can be turned on and off + +>>> await dev.turn_on() +>>> await dev.update() +>>> print(dev.is_on) +True + +>>> await dev.turn_off() +>>> await dev.update() +>>> print(dev.is_on) +False + +All devices provide several informational properties: + +>>> dev.alias +Bedroom Lamp Plug +>>> dev.model +HS110 +>>> dev.rssi +-71 +>>> dev.mac +50:C7:BF:00:00:00 + +Some information can also be changed programmatically: + +>>> await dev.set_alias("new alias") +>>> await dev.update() +>>> dev.alias +new alias + +Devices support different functionality that are exposed via +:ref:`modules ` that you can access via :attr:`~kasa.Device.modules`: + +>>> for module_name in dev.modules: +>>> print(module_name) +homekit +Energy +schedule +usage +anti_theft +Time +cloud +Led + +>>> led_module = dev.modules["Led"] +>>> print(led_module.led) +False +>>> await led_module.set_led(True) +>>> await dev.update() +>>> print(led_module.led) +True + +Individual pieces of functionality are also exposed via :ref:`features ` +which you can access via :attr:`~kasa.Device.features` and will only be present if +they are supported. + +Features are similar to modules in that they provide functionality that may or may +not be present. + +Whereas modules group functionality into a common interface, features expose a single +function that may or may not be part of a module. + +The advantage of features is that they have a simple common interface of `id`, `name`, +`value` and `set_value` so no need to learn the module API. + +They are useful if you want write code that dynamically adapts as new features are +added to the API. + +>>> for feature_name in dev.features: +>>> print(feature_name) +state +rssi +on_since +reboot +... +current_consumption +consumption_today +consumption_this_month +consumption_total +voltage +current +cloud_connection +led + +>>> led_feature = dev.features["led"] +>>> print(led_feature.value) +True +>>> await led_feature.set_value(False) +>>> await dev.update() +>>> print(led_feature.value) +False +""" + +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime, tzinfo +from typing import TYPE_CHECKING, Any, TypeAlias +from warnings import warn + +from .credentials import Credentials as _Credentials +from .device_type import DeviceType +from .deviceconfig import ( + DeviceConfig, + DeviceConnectionParameters, + DeviceEncryptionType, + DeviceFamily, +) +from .exceptions import KasaException +from .feature import Feature +from .module import Module +from .protocols import BaseProtocol, IotProtocol +from .transports import XorTransport + +if TYPE_CHECKING: + from .modulemapping import ModuleMapping, ModuleName + + +@dataclass +class WifiNetwork: + """Wifi network container.""" + + ssid: str + # 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 + channel: int | None = None + # These are available on softaponboarding, SMART, and SMARTCAM devices + bssid: str | None = None + rssi: int | None = None + # 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: + """Device Model Information.""" + + short_name: str + long_name: str + brand: str + device_family: str + device_type: DeviceType + hardware_version: str + firmware_version: str + firmware_build: str | None + requires_auth: bool + region: str | None + + +class Device(ABC): + """Common device interface. + + Do not instantiate this class directly, instead get a device instance from + :func:`Device.connect()`, :func:`Discover.discover()` + or :func:`Discover.discover_single()`. + """ + + # All types required to create devices directly via connect are aliased here + # to avoid consumers having to do multiple imports. + + #: The type of device + Type: TypeAlias = DeviceType + #: The credentials for authentication + Credentials: TypeAlias = _Credentials + #: Configuration for connecting to the device + Config: TypeAlias = DeviceConfig + #: The family of the device, e.g. SMART.KASASWITCH. + Family: TypeAlias = DeviceFamily + #: The encryption for the device, e.g. Klap or Aes + EncryptionType: TypeAlias = DeviceEncryptionType + #: The connection type for the device. + ConnectionParameters: TypeAlias = DeviceConnectionParameters + + def __init__( + self, + host: str, + *, + config: DeviceConfig | None = None, + protocol: BaseProtocol | None = None, + ) -> None: + """Create a new Device instance. + + :param str host: host name or IP address of the device + :param DeviceConfig config: device configuration + :param BaseProtocol protocol: protocol for communicating with the device + """ + if config and protocol: + protocol._transport._config = config + self.protocol: BaseProtocol = protocol or IotProtocol( + transport=XorTransport(config=config or DeviceConfig(host=host)), + ) + 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 + # checks in accessors. the @updated_required decorator does not ensure + # mypy that these are not accessed incorrectly. + self._discovery_info: dict[str, Any] | None = None + + self._features: dict[str, Feature] = {} + self._parent: Device | None = None + self._children: Mapping[str, Device] = {} + + @staticmethod + async def connect( + *, + host: str | None = None, + config: DeviceConfig | None = None, + ) -> Device: + """Connect to a single device by the given hostname or device configuration. + + This method avoids the UDP based discovery process and + will connect directly to the device. + + It is generally preferred to avoid :func:`discover_single()` and + use this function instead as it should perform better when + the WiFi network is congested or the device is not responding + to discovery requests. + + :param host: Hostname of device to query + :param config: Connection parameters to ensure the correct protocol + and connection options are used. + :rtype: SmartDevice + :return: Object for querying/controlling found device. + """ + from .device_factory import connect # pylint: disable=import-outside-toplevel + + return await connect(host=host, config=config) # type: ignore[arg-type] + + @abstractmethod + async def update(self, update_children: bool = True) -> None: + """Update the device.""" + + async def disconnect(self) -> None: + """Disconnect and close any underlying connection resources.""" + await self.protocol.close() + + @property + @abstractmethod + def modules(self) -> ModuleMapping[Module]: + """Return the device modules.""" + + @property + @abstractmethod + def is_on(self) -> bool: + """Return true if the device is on.""" + + @property + def is_off(self) -> bool: + """Return True if device is off.""" + return not self.is_on + + @abstractmethod + async def turn_on(self, **kwargs) -> dict: + """Turn on the device.""" + + @abstractmethod + async def turn_off(self, **kwargs) -> dict: + """Turn off the device.""" + + @abstractmethod + async def set_state(self, on: bool) -> dict: + """Set the device state to *on*. + + This allows turning the device on and off. + See also *turn_off* and *turn_on*. + """ + + @property + def host(self) -> str: + """The device host.""" + return self.protocol._transport._host + + @host.setter + def host(self, value: str) -> None: + """Set the device host. + + Generally used by discovery to set the hostname after ip discovery. + """ + self.protocol._transport._host = value + self.protocol._transport._config.host = value + + @property + def port(self) -> int: + """The device port.""" + return self.protocol._transport._port + + @property + def credentials(self) -> _Credentials | None: + """The device credentials.""" + return self.protocol._transport._credentials + + @property + def credentials_hash(self) -> str | None: + """The protocol specific hash of the credentials the device is using.""" + return self.protocol._transport.credentials_hash + + @property + def device_type(self) -> DeviceType: + """Return the device type.""" + return self._device_type + + @abstractmethod + def update_from_discover_info(self, info: dict) -> None: + """Update state from info from the discover call.""" + + @property + def config(self) -> DeviceConfig: + """Return the device configuration.""" + return self.protocol.config + + @property + @abstractmethod + 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 _get_device_info( + info: dict[str, Any], discovery_info: dict[str, Any] | None + ) -> DeviceInfo: + """Get device info.""" + + @property + @abstractmethod + def alias(self) -> str | None: + """Returns the device alias or nickname.""" + + async def _raw_query(self, request: str | dict) -> dict: + """Send a raw query to the device.""" + return await self.protocol.query(request=request) + + @property + def parent(self) -> Device | None: + """Return the parent on child devices.""" + return self._parent + + @property + def children(self) -> Sequence[Device]: + """Returns the child devices.""" + return list(self._children.values()) + + def get_child_device(self, name_or_id: str) -> Device | None: + """Return child device by its device_id or alias.""" + if name_or_id in self._children: + return self._children[name_or_id] + name_lower = name_or_id.lower() + for child in self.children: + if child.alias and child.alias.lower() == name_lower: + return child + return None + + @property + @abstractmethod + def sys_info(self) -> dict[str, Any]: + """Returns the device info.""" + + def get_plug_by_name(self, name: str) -> Device: + """Return child device for the given name.""" + for p in self.children: + if p.alias == name: + return p + + raise KasaException(f"Device has no child with {name}") + + def get_plug_by_index(self, index: int) -> Device: + """Return child device for the given index.""" + if index + 1 > len(self.children) or index < 0: + raise KasaException( + f"Invalid index {index}, device has {len(self.children)} plugs" + ) + return self.children[index] + + @property + @abstractmethod + def time(self) -> datetime: + """Return the time.""" + + @property + @abstractmethod + def timezone(self) -> tzinfo: + """Return the timezone and time_difference.""" + + @property + @abstractmethod + def hw_info(self) -> dict: + """Return hardware info for the device.""" + + @property + @abstractmethod + def location(self) -> dict: + """Return the device location.""" + + @property + @abstractmethod + def rssi(self) -> int | None: + """Return the rssi.""" + + @property + @abstractmethod + def mac(self) -> str: + """Return the mac formatted with colons.""" + + @property + @abstractmethod + def device_id(self) -> str: + """Return the device id.""" + + @property + @abstractmethod + def internal_state(self) -> dict: + """Return all the internal state data.""" + + @property + def state_information(self) -> dict[str, Any]: + """Return available features and their values.""" + return {feat.name: feat.value for feat in self._features.values()} + + @property + def features(self) -> dict[str, Feature]: + """Return the list of supported features.""" + return self._features + + def _add_feature(self, feature: Feature) -> None: + """Add a new feature to the device.""" + if feature.id in self._features: + raise KasaException(f"Duplicate feature id {feature.id}") + assert feature.id is not None # TODO: hack for typing # noqa: S101 + self._features[feature.id] = feature + + @property + @abstractmethod + def has_emeter(self) -> bool: + """Return if the device has emeter.""" + + @property + @abstractmethod + def on_since(self) -> datetime | None: + """Return the time that the device was turned on or None if turned off. + + This returns a cached value if the device reported value difference is under + five seconds to avoid device-caused jitter. + """ + + @abstractmethod + async def wifi_scan(self) -> list[WifiNetwork]: + """Scan for available wifi networks.""" + + @abstractmethod + async def wifi_join( + self, ssid: str, password: str, keytype: str = "wpa2_psk" + ) -> dict: + """Join the given wifi network.""" + + @abstractmethod + async def set_alias(self, alias: str) -> dict: + """Set the device name (alias).""" + + @abstractmethod + async def reboot(self, delay: int = 1) -> None: + """Reboot the device. + + Note that giving a delay of zero causes this to block, + as the device reboots immediately without responding to the call. + """ + + @abstractmethod + async def factory_reset(self) -> None: + """Reset device back to factory settings. + + Note, this does not downgrade the firmware. + """ + + def __repr__(self) -> str: + update_needed = " - update() needed" if not self._last_update else "" + if not self._last_update and not self._discovery_info: + return f"<{self.device_type} at {self.host}{update_needed}>" + return ( + f"<{self.device_type} at {self.host} -" + f" {self.alias} ({self.model}){update_needed}>" + ) + + _deprecated_device_type_attributes = { + # is_type + "is_bulb": (None, DeviceType.Bulb), + "is_dimmer": (None, DeviceType.Dimmer), + "is_light_strip": (None, DeviceType.LightStrip), + "is_plug": (None, DeviceType.Plug), + "is_wallswitch": (None, DeviceType.WallSwitch), + "is_strip": (None, DeviceType.Strip), + "is_strip_socket": (None, DeviceType.StripSocket), + } + + def _get_replacing_attr( + self, module_name: ModuleName | None, *attrs: Any + ) -> str | None: + # If module name is None check self + if not module_name: + check = self + elif (check := self.modules.get(module_name)) is None: + return None + + for attr in attrs: + # Use dir() as opposed to hasattr() to avoid raising exceptions + # from properties + if attr in dir(check): + return 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 + "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"]), + "_deprecated_set_light_state": (Module.Light, ["has_effects"]), + # led attributes + "led": (Module.Led, ["led"]), + "set_led": (Module.Led, ["set_led"]), + # light effect attributes + # The return values for effect is a str instead of dict so the lightstrip + # modules have a _deprecated method to return the value as before. + "effect": (Module.LightEffect, ["_deprecated_effect", "effect"]), + # The return values for effect_list includes the Off effect so the lightstrip + # modules have a _deprecated method to return the values as before. + "effect_list": (Module.LightEffect, ["_deprecated_effect_list", "effect_list"]), + "set_effect": (Module.LightEffect, ["set_effect"]), + "set_custom_effect": (Module.LightEffect, ["set_custom_effect"]), + # light preset attributes + "presets": (Module.LightPreset, ["_deprecated_presets", "preset_states_list"]), + "save_preset": (Module.LightPreset, ["_deprecated_save_preset"]), + # Emeter attribues + "get_emeter_realtime": (Module.Energy, ["get_status"]), + "emeter_realtime": (Module.Energy, ["status"]), + "emeter_today": (Module.Energy, ["consumption_today"]), + "emeter_this_month": (Module.Energy, ["consumption_this_month"]), + "current_consumption": (Module.Energy, ["current_consumption"]), + "get_emeter_daily": (Module.Energy, ["get_daily_stats"]), + "get_emeter_monthly": (Module.Energy, ["get_monthly_stats"]), + # Other attributes + "supported_modules": (None, ["modules"]), + } + + if not TYPE_CHECKING: + + def __getattr__(self, name: str) -> Any: + # is_device_type + if dep_device_type_attr := self._deprecated_device_type_attributes.get( + name + ): + 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])) + is not None + ): + mod = dep_attr[0] + dev_or_mod = self.modules[mod] if mod else self + replacing = f"Module.{mod} in device.modules" if mod else replacing_attr + msg = f"{name} is deprecated, use: {replacing} instead" + warn(msg, DeprecationWarning, stacklevel=2) + return getattr(dev_or_mod, replacing_attr) + raise AttributeError(f"Device has no attribute {name!r}") diff --git a/kasa/device_factory.py b/kasa/device_factory.py new file mode 100644 index 000000000..ecb0d0a13 --- /dev/null +++ b/kasa/device_factory.py @@ -0,0 +1,242 @@ +"""Device creation via DeviceConfig.""" + +from __future__ import annotations + +import logging +import time +from typing import Any + +from .device import Device +from .device_type import DeviceType +from .deviceconfig import DeviceConfig, DeviceEncryptionType, DeviceFamily +from .exceptions import KasaException, UnsupportedDeviceError +from .iot import ( + IotBulb, + IotDevice, + IotDimmer, + IotLightStrip, + IotPlug, + IotStrip, + IotWallSwitch, +) +from .protocols import ( + BaseProtocol, + IotProtocol, + SmartProtocol, +) +from .protocols.smartcamprotocol import SmartCamProtocol +from .smart import SmartDevice +from .smartcam import SmartCamDevice +from .transports import ( + AesTransport, + BaseTransport, + KlapTransport, + KlapTransportV2, + LinkieTransportV2, + SslTransport, + XorTransport, +) +from .transports.sslaestransport import SslAesTransport + +_LOGGER = logging.getLogger(__name__) + +GET_SYSINFO_QUERY: dict[str, dict[str, dict]] = { + "system": {"get_sysinfo": {}}, +} + + +async def connect(*, host: str | None = None, config: DeviceConfig) -> Device: + """Connect to a single device by the given hostname or device configuration. + + This method avoids the UDP based discovery process and + will connect directly to the device. + + It is generally preferred to avoid :func:`discover_single()` and + use this function instead as it should perform better when + the WiFi network is congested or the device is not responding + to discovery requests. + + Do not use this function directly, use SmartDevice.connect() + + :param host: Hostname of device to query + :param config: Connection parameters to ensure the correct protocol + and connection options are used. + :rtype: SmartDevice + :return: Object for querying/controlling found device. + """ + if host and config or (not host and not config): + raise KasaException("One of host or config must be provded and not both") + if host: + config = DeviceConfig(host=host) + + if (protocol := get_protocol(config=config)) is None: + raise UnsupportedDeviceError( + f"Unsupported device for {config.host}: " + + f"{config.connection_type.device_family.value}", + host=config.host, + ) + + try: + return await _connect(config, protocol) + except: + await protocol.close() + raise + + +async def _connect(config: DeviceConfig, protocol: BaseProtocol) -> Device: + debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) + if debug_enabled: + start_time = time.perf_counter() + + def _perf_log(has_params: bool, perf_type: str) -> None: + nonlocal start_time + if debug_enabled: + end_time = time.perf_counter() + _LOGGER.debug( + "Device %s with connection params %s took %.2f seconds to %s", + config.host, + has_params, + end_time - start_time, + perf_type, + ) + start_time = time.perf_counter() + + device_class: type[Device] | None + device: Device | None = None + + if isinstance(protocol, IotProtocol) and isinstance( + protocol._transport, XorTransport + ): + info = await protocol.query(GET_SYSINFO_QUERY) + _perf_log(True, "get_sysinfo") + device_class = get_device_class_from_sys_info(info) + device = device_class(config.host, protocol=protocol) + device.update_from_discover_info(info) + await device.update() + _perf_log(True, "update") + return device + elif device_class := get_device_class_from_family( + config.connection_type.device_family.value, https=config.connection_type.https + ): + device = device_class(host=config.host, protocol=protocol) + await device.update() + _perf_log(True, "update") + return device + else: + raise UnsupportedDeviceError( + f"Unsupported device for {config.host}: " + + f"{config.connection_type.device_family.value}", + host=config.host, + ) + + +def get_device_class_from_sys_info(sysinfo: dict[str, Any]) -> type[IotDevice]: + """Find SmartDevice subclass for device described by passed data.""" + TYPE_TO_CLASS = { + DeviceType.Bulb: IotBulb, + DeviceType.Plug: IotPlug, + DeviceType.Dimmer: IotDimmer, + 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)] + + +def get_device_class_from_family( + device_type: str, *, https: bool, require_exact: bool = False +) -> type[Device] | None: + """Return the device class from the type name.""" + supported_device_types: dict[str, type[Device]] = { + "SMART.TAPOPLUG": SmartDevice, + "SMART.TAPOBULB": SmartDevice, + "SMART.TAPOSWITCH": SmartDevice, + "SMART.KASAPLUG": SmartDevice, + "SMART.TAPOHUB": SmartDevice, + "SMART.TAPOHUB.HTTPS": SmartCamDevice, + "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 ( + (cls := supported_device_types.get(lookup_key)) is None + and device_type.startswith("SMART.") + and not require_exact + ): + _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, *, 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]] + ] = { + "IOT.XOR": (IotProtocol, XorTransport), + "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)): + return None + protocol_cls, transport_cls = prot_tran_cls + return protocol_cls(transport=transport_cls(config=config)) diff --git a/kasa/device_type.py b/kasa/device_type.py new file mode 100755 index 000000000..d39962179 --- /dev/null +++ b/kasa/device_type.py @@ -0,0 +1,35 @@ +"""TP-Link device types.""" + +from __future__ import annotations + +from enum import Enum + + +class DeviceType(Enum): + """Device type enum.""" + + # The values match what the cli has historically used + Plug = "plug" + Bulb = "bulb" + Strip = "strip" + Camera = "camera" + WallSwitch = "wallswitch" + StripSocket = "stripsocket" + Dimmer = "dimmer" + LightStrip = "lightstrip" + Sensor = "sensor" + Hub = "hub" + Fan = "fan" + Thermostat = "thermostat" + Vacuum = "vacuum" + Chime = "chime" + Doorbell = "doorbell" + Unknown = "unknown" + + @staticmethod + def from_value(name: str) -> DeviceType: + """Return device type from string value.""" + for device_type in DeviceType: + if device_type.value == name: + return device_type + return DeviceType.Unknown diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py new file mode 100644 index 000000000..2b669f809 --- /dev/null +++ b/kasa/deviceconfig.py @@ -0,0 +1,202 @@ +"""Configuration for connecting directly to a device without discovery. + +If you are connecting to a newer KASA or TAPO device you can get the device +via discovery or connect directly with :class:`DeviceConfig`. + +Discovery returns a list of discovered devices: + +>>> from kasa import Discover, Device +>>> device = await Discover.discover_single( +>>> "127.0.0.3", +>>> username="user@example.com", +>>> password="great_password", +>>> ) +>>> print(device.alias) # Alias is None because update() has not been called +None + +>>> config_dict = device.config.to_dict() +>>> # DeviceConfig.to_dict() can be used to store for later +>>> print(config_dict) +{'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, '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() +Living Room Bulb + +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field, replace +from enum import Enum +from typing import TYPE_CHECKING, TypedDict + +from aiohttp import ClientSession +from mashumaro import field_options, pass_through +from mashumaro.config import BaseConfig + +from .credentials import Credentials +from .exceptions import KasaException +from .json import DataClassJSONMixin + +if TYPE_CHECKING: + from aiohttp import ClientSession + +_LOGGER = logging.getLogger(__name__) + + +class KeyPairDict(TypedDict): + """Class to represent a public/private key pair.""" + + private: str + public: str + + +class DeviceEncryptionType(Enum): + """Encrypt type enum.""" + + Klap = "KLAP" + Aes = "AES" + Xor = "XOR" + + +class DeviceFamily(Enum): + """Encrypt type enum.""" + + IotSmartPlugSwitch = "IOT.SMARTPLUGSWITCH" + IotSmartBulb = "IOT.SMARTBULB" + IotIpCamera = "IOT.IPCAMERA" + SmartKasaPlug = "SMART.KASAPLUG" + SmartKasaSwitch = "SMART.KASASWITCH" + SmartTapoPlug = "SMART.TAPOPLUG" + SmartTapoBulb = "SMART.TAPOBULB" + SmartTapoSwitch = "SMART.TAPOSWITCH" + SmartTapoHub = "SMART.TAPOHUB" + SmartKasaHub = "SMART.KASAHUB" + SmartIpCamera = "SMART.IPCAMERA" + SmartTapoRobovac = "SMART.TAPOROBOVAC" + SmartTapoChime = "SMART.TAPOCHIME" + SmartTapoDoorbell = "SMART.TAPODOORBELL" + + +class _DeviceConfigBaseMixin(DataClassJSONMixin): + """Base class for serialization mixin.""" + + class Config(BaseConfig): + """Serialization config.""" + + omit_none = True + + +@dataclass +class DeviceConnectionParameters(_DeviceConfigBaseMixin): + """Class to hold the the parameters determining connection type.""" + + device_family: DeviceFamily + 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: + if https is None: + https = False + return DeviceConnectionParameters( + DeviceFamily(device_family), + DeviceEncryptionType(encryption_type), + login_version, + https, + http_port=http_port, + ) + except (ValueError, TypeError) as ex: + raise KasaException( + f"Invalid connection parameters for {device_family}." + + f"{encryption_type}.{login_version}" + ) from ex + + +@dataclass +class DeviceConfig(_DeviceConfigBaseMixin): + """Class to represent paramaters that determine how to connect to devices.""" + + DEFAULT_TIMEOUT = 5 + #: IP address or hostname + host: str + #: Timeout for querying the device + timeout: int | None = DEFAULT_TIMEOUT + #: Override the default 9999 port to support port forwarding + port_override: int | None = None + #: Credentials for devices requiring authentication + credentials: Credentials | None = None + #: Credentials hash for devices requiring authentication. + #: If credentials are also supplied they take precendence over credentials_hash. + #: Credentials hash can be retrieved from :attr:`Device.credentials_hash` + credentials_hash: str | None = None + #: The protocol specific type of connection. Defaults to the legacy type. + batch_size: int | None = None + #: The batch size for protoools supporting multiple request batches. + connection_type: DeviceConnectionParameters = field( + default_factory=lambda: DeviceConnectionParameters( + DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Xor + ) + ) + + @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( + default=None, + compare=False, + metadata=field_options(serialize="omit", deserialize=pass_through), + ) + + aes_keys: KeyPairDict | None = None + + def __post_init__(self) -> None: + if self.connection_type is None: + self.connection_type = DeviceConnectionParameters( + DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Xor + ) + + def to_dict_control_credentials( + self, + *, + credentials_hash: str | None = None, + exclude_credentials: bool = False, + ) -> dict[str, dict[str, str]]: + """Convert deviceconfig to dict controlling how to serialize credentials. + + If credentials_hash is provided credentials will be None. + If credentials_hash is '' credentials_hash and credentials will be None. + exclude credentials controls whether to include credentials. + The defaults are the same as calling to_dict(). + """ + if credentials_hash is None: + if not exclude_credentials: + return self.to_dict() + else: + return replace(self, credentials=None).to_dict() + + return replace( + self, + credentials_hash=credentials_hash if credentials_hash else None, + credentials=None, + ).to_dict() diff --git a/kasa/discover.py b/kasa/discover.py index 7aaf85245..e03f7187f 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -1,22 +1,233 @@ -"""Discovery module for TP-Link Smart Home devices.""" +"""Discover TPLink Smart Home devices. + +The main entry point for this library is :func:`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 +IP address. If the :class:`DeviceConfig` of the device is already known, +you can initialize the corresponding device class directly without discovery. + +The protocol uses UDP broadcast datagrams on port 9999 and 20002 for discovery. +Legacy devices support discovery on port 9999 and newer devices on 20002. + +Newer devices that respond on port 20002 will most likely require TP-Link cloud +credentials to be passed if queries or updates are to be performed on the returned +devices. + +Discovery returns a dict of {ip: discovered devices}: + +>>> from kasa import Discover, Credentials +>>> +>>> found_devices = await Discover.discover() +>>> [dev.model for dev in found_devices.values()] +['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", +>>> ) +>>> print(len(devices)) +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)) +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)) +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 +all the attributes without getting errors or None. + +>>> dev = found_devices["127.0.0.3"] +>>> dev.alias +None +>>> await dev.update() +>>> dev.alias +'Living Room Bulb' + +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})") +>>> +>>> devices = await Discover.discover(on_discovered=print_dev_info, credentials=creds) +Discovered Bedroom Power Strip (model: KP303) +Discovered Bedroom Lamp Plug (model: HS110) +Discovered Living Room Bulb (model: L530) +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' + +""" + +from __future__ import annotations + import asyncio -import json +import base64 +import binascii +import ipaddress import logging +import secrets import socket -from typing import Awaitable, Callable, Dict, Mapping, Optional, Type, Union, cast - -from kasa.protocol import TPLinkSmartHomeProtocol -from kasa.smartbulb import SmartBulb -from kasa.smartdevice import SmartDevice, SmartDeviceException -from kasa.smartdimmer import SmartDimmer -from kasa.smartlightstrip import SmartLightStrip -from kasa.smartplug import SmartPlug -from kasa.smartstrip import SmartStrip +import struct +from asyncio import timeout as asyncio_timeout +from asyncio.transports import DatagramTransport +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from pprint import pformat as pf +from typing import ( + TYPE_CHECKING, + Annotated, + Any, + NamedTuple, + TypedDict, + cast, +) + +from aiohttp import ClientSession +from mashumaro.config import BaseConfig +from mashumaro.types import Alias + +from kasa import Device +from kasa.credentials import Credentials +from kasa.device_factory import ( + get_device_class_from_family, + get_device_class_from_sys_info, + get_protocol, +) +from kasa.deviceconfig import ( + DeviceConfig, + DeviceConnectionParameters, + DeviceEncryptionType, +) +from kasa.exceptions import ( + KasaException, + TimeoutError, + UnsupportedDeviceError, +) +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 +from kasa.protocols.iotprotocol import REDACTORS as IOT_REDACTORS +from kasa.protocols.protocol import mask_mac, redact_data +from kasa.transports.aestransport import AesEncyptionSession, KeyPair +from kasa.transports.xortransport import XorEncryption _LOGGER = logging.getLogger(__name__) +if TYPE_CHECKING: + from kasa import BaseProtocol + from kasa.transports import BaseTransport + + +class ConnectAttempt(NamedTuple): + """Try to connect attempt.""" + + 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), +} + + +class _AesDiscoveryQuery: + keypair: KeyPair | None = None + + @classmethod + def generate_query(cls) -> bytearray: + if not cls.keypair: + cls.keypair = KeyPair.create_key_pair(key_size=2048) + secret = secrets.token_bytes(4) + + key_payload = {"params": {"rsa_key": cls.keypair.get_public_pem().decode()}} + + key_payload_bytes = json_dumps(key_payload).encode() + # https://labs.withsecure.com/advisories/tp-link-ac1750-pwn2own-2019 + version = 2 # version of tdp + msg_type = 0 + op_code = 1 # probe + msg_size = len(key_payload_bytes) + flags = 17 + padding_byte = 0 # blank byte + device_serial = int.from_bytes(secret, "big") + initial_crc = 0x5A6B7C8D + + disco_header = struct.pack( + ">BBHHBBII", + version, + msg_type, + op_code, + msg_size, + flags, + padding_byte, + device_serial, + initial_crc, + ) -OnDiscoveredCallable = Callable[[SmartDevice], Awaitable[None]] + query = bytearray(disco_header + key_payload_bytes) + crc = binascii.crc32(query).to_bytes(length=4, byteorder="big") + query[12:16] = crc + return query class _DiscoverProtocol(asyncio.DatagramProtocol): @@ -25,243 +236,754 @@ class _DiscoverProtocol(asyncio.DatagramProtocol): This is internal class, use :func:`Discover.discover`: instead. """ - discovered_devices: Dict[str, SmartDevice] - discovered_devices_raw: Dict[str, Dict] + DISCOVERY_START_TIMEOUT = 1 + + discovered_devices: DeviceDict def __init__( self, *, - on_discovered: OnDiscoveredCallable = None, + on_discovered: OnDiscoveredCallable | None = None, + on_discovered_raw: OnDiscoveredRawCallable | None = None, target: str = "255.255.255.255", - timeout: int = 5, discovery_packets: int = 3, - interface: Optional[str] = None, - ): - self.transport = None - self.tries = discovery_packets - self.timeout = timeout + discovery_timeout: int = 5, + interface: str | None = None, + on_unsupported: OnUnsupportedCallable | None = None, + port: int | None = None, + credentials: Credentials | None = None, + timeout: int | None = None, + ) -> None: + self.transport: DatagramTransport | None = None + self.discovery_packets = discovery_packets self.interface = interface self.on_discovered = on_discovered - self.protocol = TPLinkSmartHomeProtocol() - self.target = (target, Discover.DISCOVERY_PORT) - self.discovered_devices = {} - self.discovered_devices_raw = {} - def connection_made(self, transport) -> None: + self.port = port + self.discovery_port = port or Discover.DISCOVERY_PORT + 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 + self.seen_hosts: set[str] = set() + self.discover_task: asyncio.Task | None = None + self.callback_tasks: list[asyncio.Task] = [] + self.target_discovered: bool = False + self._started_event = asyncio.Event() + + def _run_callback_task(self, coro: Coroutine) -> None: + task: asyncio.Task = asyncio.create_task(coro) + self.callback_tasks.append(task) + + async def wait_for_discovery_to_complete(self) -> None: + """Wait for the discovery task to complete.""" + # Give some time for connection_made event to be received + async with asyncio_timeout(self.DISCOVERY_START_TIMEOUT): + await self._started_event.wait() + try: + if TYPE_CHECKING: + assert isinstance(self.discover_task, asyncio.Task) + + await self.discover_task + except asyncio.CancelledError: + # if target_discovered then cancel was called internally + if not self.target_discovered: + raise + # Wait for any pending callbacks to complete + await asyncio.gather(*self.callback_tasks) + + def connection_made(self, transport: DatagramTransport) -> None: # type: ignore[override] """Set socket options for broadcasting.""" - self.transport = transport - sock = transport.get_extra_info("socket") + self.transport = cast(DatagramTransport, transport) + + sock = self.transport.get_extra_info("socket") sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - if self.interface is not None: + try: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + except OSError as ex: # WSL does not support SO_REUSEADDR, see #246 + _LOGGER.debug("Unable to set SO_REUSEADDR: %s", ex) + + # windows does not support SO_BINDTODEVICE + if self.interface is not None and hasattr(socket, "SO_BINDTODEVICE"): sock.setsockopt( socket.SOL_SOCKET, socket.SO_BINDTODEVICE, self.interface.encode() ) - self.do_discover() + self.discover_task = asyncio.create_task(self.do_discover()) + self._started_event.set() - def do_discover(self) -> None: + async def do_discover(self) -> None: """Send number of discovery datagrams.""" - req = json.dumps(Discover.DISCOVERY_QUERY) + req = json_dumps(Discover.DISCOVERY_QUERY) _LOGGER.debug("[DISCOVERY] %s >> %s", self.target, Discover.DISCOVERY_QUERY) - encrypted_req = self.protocol.encrypt(req) - for i in range(self.tries): - self.transport.sendto(encrypted_req[4:], self.target) # type: ignore - - def datagram_received(self, data, addr) -> None: + encrypted_req = XorEncryption.encrypt(req) + sleep_between_packets = self.discovery_timeout / self.discovery_packets + + aes_discovery_query = _AesDiscoveryQuery.generate_query() + for _ in range(self.discovery_packets): + if self.target in self.seen_hosts: # Stop sending for discover_single + 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( + self, + data: bytes, + addr: tuple[str, int], + ) -> None: """Handle discovery responses.""" + if TYPE_CHECKING: + assert _AesDiscoveryQuery.keypair + ip, port = addr - if ip in self.discovered_devices: + # Prevent multiple entries due multiple broadcasts + if ip in self.seen_hosts: return + self.seen_hosts.add(ip) - info = json.loads(self.protocol.decrypt(data)) - _LOGGER.debug("[DISCOVERY] %s << %s", ip, info) + device: Device | None = None - device_class = Discover._get_device_class(info) - device = device_class(ip) - asyncio.ensure_future(device.update()) + config = DeviceConfig(host=ip, port_override=self.port) + if self.credentials: + config.credentials = self.credentials + if self.timeout: + config.timeout = self.timeout + try: + if port == self.discovery_port: + 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 + if self.on_unsupported is not None: + self._run_callback_task(self.on_unsupported(udex)) + self._handle_discovered_event() + return + except KasaException as ex: + _LOGGER.debug("[DISCOVERY] Unable to find device type for %s: %s", ip, ex) + self.invalid_device_exceptions[ip] = ex + self._handle_discovered_event() + return self.discovered_devices[ip] = device - self.discovered_devices_raw[ip] = info - if device_class is not None: - if self.on_discovered is not None: - asyncio.ensure_future(self.on_discovered(device)) - else: - _LOGGER.error("Received invalid response: %s", info) + if self.on_discovered is not None: + self._run_callback_task(self.on_discovered(device)) + + self._handle_discovered_event() - def error_received(self, ex): + def _handle_discovered_event(self) -> None: + """If target is in seen_hosts cancel discover_task.""" + if self.target in self.seen_hosts: + self.target_discovered = True + if self.discover_task: + self.discover_task.cancel() + + def error_received(self, ex: Exception) -> None: """Handle asyncio.Protocol errors.""" _LOGGER.error("Got error: %s", ex) - def connection_lost(self, ex): - """NOP implementation of connection lost.""" + def connection_lost(self, ex: Exception | None) -> None: # pragma: no cover + """Cancel the discover task if running.""" + if self.discover_task: + self.discover_task.cancel() class Discover: - """Discover TPLink Smart Home devices. - - The main entry point for this library is :func:`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 - IP address. If the type of the device and its IP address is already known, - you can initialize the corresponding device class directly without this. - - The protocol uses UDP broadcast datagrams on port 9999 for discovery. - - Examples: - Discovery returns a list of discovered devices: - - >>> import asyncio - >>> found_devices = asyncio.run(Discover.discover()) - >>> [dev.alias for dev in found_devices] - ['TP-LINK_Power Strip_CF69'] - - Discovery can also be targeted to a specific broadcast address instead of the 255.255.255.255: - - >>> asyncio.run(Discover.discover(target="192.168.8.255")) - - It is also possible to pass a coroutine to be executed for each found device: - - >>> async def print_alias(dev): - >>> print(f"Discovered {dev.alias}") - >>> devices = asyncio.run(Discover.discover(on_discovered=print_alias)) - - - """ + """Class for discovering devices.""" DISCOVERY_PORT = 9999 - DISCOVERY_QUERY = { - "system": {"get_sysinfo": None}, - "emeter": {"get_realtime": None}, - "smartlife.iot.dimmer": {"get_dimmer_parameters": None}, - "smartlife.iot.common.emeter": {"get_realtime": None}, - "smartlife.iot.smartbulb.lightingservice": {"get_light_state": None}, + DISCOVERY_QUERY: dict[str, dict[str, dict]] = { + "system": {"get_sysinfo": {}}, } + DISCOVERY_PORT_2 = 20002 + DISCOVERY_PORT_3 = 20004 + DISCOVERY_QUERY_2 = binascii.unhexlify("020000010000000000000000463cb5d3") + + _redact_data = True + @staticmethod async def discover( *, - target="255.255.255.255", - on_discovered=None, - timeout=5, - discovery_packets=3, - return_raw=False, - interface=None, - ) -> Mapping[str, Union[SmartDevice, Dict]]: + 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, + on_unsupported: OnUnsupportedCallable | None = None, + credentials: Credentials | None = None, + username: str | None = None, + password: str | None = None, + port: int | None = None, + timeout: int | None = None, + ) -> DeviceDict: """Discover supported devices. - Sends discovery message to 255.255.255.255:9999 in order + Sends discovery message to 255.255.255.255:9999 and + 255.255.255.255:20002 in order to detect available supported devices in the local network, and waits for given timeout for answers from devices. + If you have multiple interfaces, + you can use *target* parameter to specify the network for discovery. - If given, `on_discovered` coroutine will get passed with the :class:`SmartDevice`-derived object as parameter. + If given, `on_discovered` coroutine will get awaited with + a :class:`Device`-derived object as parameter. - The results of the discovery are returned either as a list of :class:`SmartDevice`-derived objects - or as raw response dictionaries objects (if `return_raw` is True). + The results of the discovery are returned as a dict of + :class:`Device`-derived objects keyed with IP addresses. + The devices are already initialized and all but emeter-related properties + can be accessed directly. - :param target: The target broadcast address (e.g. 192.168.xxx.255). + :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 timeout: How long to wait for responses, defaults to 5 - :param discovery_packets: Number of discovery packets are broadcasted. - :param return_raw: True to return JSON objects instead of Devices. - :return: + :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 + :param on_unsupported: Optional callback when unsupported devices are discovered + :param credentials: Credentials for devices that require authentication. + username and password are ignored if provided. + :param username: Username for devices that require authentication + :param password: Password for devices that require authentication + :param port: Override the discovery port for devices listening on 9999 + :param timeout: Query timeout in seconds for devices returned by discovery + :return: dictionary with discovered devices """ + if not credentials and username and password: + credentials = Credentials(username, password) loop = asyncio.get_event_loop() transport, protocol = await loop.create_datagram_endpoint( lambda: _DiscoverProtocol( target=target, on_discovered=on_discovered, - timeout=timeout, discovery_packets=discovery_packets, interface=interface, + on_unsupported=on_unsupported, + on_discovered_raw=on_discovered_raw, + credentials=credentials, + timeout=timeout, + discovery_timeout=discovery_timeout, + port=port, ), - local_addr=("0.0.0.0", 0), + local_addr=("0.0.0.0", 0), # noqa: S104 ) protocol = cast(_DiscoverProtocol, protocol) try: - _LOGGER.debug("Waiting %s seconds for responses...", timeout) - await asyncio.sleep(5) + _LOGGER.debug("Waiting %s seconds for responses...", discovery_timeout) + await protocol.wait_for_discovery_to_complete() + except (KasaException, asyncio.CancelledError) as ex: + for device in protocol.discovered_devices.values(): + await device.protocol.close() + raise ex finally: transport.close() _LOGGER.debug("Discovered %s devices", len(protocol.discovered_devices)) - if return_raw: - return protocol.discovered_devices_raw - return protocol.discovered_devices @staticmethod - async def discover_single(host: str) -> SmartDevice: + async def discover_single( + host: str, + *, + discovery_timeout: int = 5, + port: int | None = None, + timeout: int | None = None, + 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. + It is generally preferred to avoid :func:`discover_single()` and + use :meth:`Device.connect()` instead as it should perform better when + the WiFi network is congested or the device is not responding + to discovery requests. + :param host: Hostname of device to query + :param discovery_timeout: Timeout in seconds for discovery + :param port: Optionally set a different port for legacy devices using port 9999 + :param timeout: Timeout in seconds device for devices queries + :param credentials: Credentials for devices that require authentication. + 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. """ - protocol = TPLinkSmartHomeProtocol() + if not credentials and username and password: + credentials = Credentials(username, password) + loop = asyncio.get_event_loop() + + try: + ipaddress.ip_address(host) + ip = host + except ValueError: + try: + adrrinfo = await loop.getaddrinfo( + host, + 0, + type=socket.SOCK_DGRAM, + family=socket.AF_INET, + ) + # getaddrinfo returns a list of 5 tuples with the following structure: + # (family, type, proto, canonname, sockaddr) + # where sockaddr is 2 tuple (ip, port). + # hence [0][4][0] is a stable array access because if no socket + # address matches the host for SOCK_DGRAM AF_INET the gaierror + # would be raised. + # https://docs.python.org/3/library/socket.html#socket.getaddrinfo + ip = adrrinfo[0][4][0] + except socket.gaierror as gex: + raise KasaException(f"Could not resolve hostname {host}") from gex + + transport, protocol = await loop.create_datagram_endpoint( + lambda: _DiscoverProtocol( + target=ip, + port=port, + credentials=credentials, + timeout=timeout, + discovery_timeout=discovery_timeout, + on_discovered_raw=on_discovered_raw, + ), + local_addr=("0.0.0.0", 0), # noqa: S104 + ) + protocol = cast(_DiscoverProtocol, protocol) - info = await protocol.query(host, Discover.DISCOVERY_QUERY) + try: + _LOGGER.debug( + "Waiting a total of %s seconds for responses...", discovery_timeout + ) + await protocol.wait_for_discovery_to_complete() + finally: + transport.close() - device_class = Discover._get_device_class(info) - if device_class is not None: - dev = device_class(host) - await dev.update() + if ip in protocol.discovered_devices: + dev = protocol.discovered_devices[ip] + dev.host = host return dev + elif ip in protocol.unsupported_device_exceptions: + if on_unsupported: + await on_unsupported(protocol.unsupported_device_exceptions[ip]) + return None + else: + raise protocol.unsupported_device_exceptions[ip] + elif ip in protocol.invalid_device_exceptions: + raise protocol.invalid_device_exceptions[ip] + else: + raise TimeoutError(f"Timed out getting discovery response for {host}") - raise SmartDeviceException("Unable to discover device, received: %s" % info) + @staticmethod + async def try_connect_all( + host: str, + *, + port: int | None = None, + timeout: int | None = None, + credentials: Credentials | None = None, + http_client: ClientSession | None = None, + on_attempt: OnConnectAttemptCallable | None = None, + ) -> Device | None: + """Try to connect directly to a device with all possible parameters. + + This method can be used when udp is not working due to network issues. + After succesfully connecting use the device config and + :meth:`Device.connect()` for future connections. + + :param host: Hostname of device to query + :param port: Optionally set a different port for legacy devices using port 9999 + :param timeout: Timeout in seconds device for devices queries + :param credentials: Credentials for devices that require authentication. + :param http_client: Optional client session for devices that use http. + username and password are ignored if provided. + """ + from .device_factory import _connect + + main_device_families = { + Device.Family.SmartTapoPlug, + Device.Family.IotSmartPlugSwitch, + Device.Family.SmartIpCamera, + Device.Family.SmartTapoRobovac, + Device.Family.IotIpCamera, + } + candidates: dict[ + tuple[type[BaseProtocol], type[BaseTransport], type[Device], bool], + tuple[BaseProtocol, DeviceConfig], + ] = { + (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, + ) + ) + and ( + config := DeviceConfig( + host=host, + connection_type=conn_params, + timeout=timeout, + port_override=port, + credentials=credentials, + http_client=http_client, + ) + ) + and (protocol := get_protocol(config, strict=True)) + and ( + device_class := get_device_class_from_family( + device_family.value, https=https, require_exact=True + ) + ) + } + 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 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) + else: + 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() + return None @staticmethod - def _get_device_class(info: dict) -> Type[SmartDevice]: + def _get_device_class(info: dict) -> type[Device]: """Find SmartDevice subclass for device described by passed data.""" - if "system" in info and "get_sysinfo" in info["system"]: - sysinfo = info["system"]["get_sysinfo"] - if "type" in sysinfo: - type_ = sysinfo["type"] - elif "mic_type" in sysinfo: - type_ = sysinfo["mic_type"] - else: - raise SmartDeviceException("Unable to find the device type field!") + if "result" in info: + discovery_result = DiscoveryResult.from_dict(info["result"]) + 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 + ) + if not dev_class: + raise UnsupportedDeviceError( + f"Unknown device type: {discovery_result.device_type}", + discovery_result=info, + ) + return dev_class else: - raise SmartDeviceException("No 'system' nor 'get_sysinfo' in response") + return get_device_class_from_sys_info(info) - if ( - "smartlife.iot.dimmer" in info - and "get_dimmer_parameters" in info["smartlife.iot.dimmer"] + @staticmethod + 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: {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 = _extract_sys_info(info) + device_type = sys_info.get("mic_type", sys_info.get("type")) + 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 + encryped_key = discovery_result.encrypt_info.key + encrypted_data = discovery_result.encrypt_info.data + + key_and_iv = _AesDiscoveryQuery.keypair.decrypt_discovery_key( + base64.b64decode(encryped_key.encode()) + ) + + key, iv = key_and_iv[:16], key_and_iv[16:] + + session = AesEncyptionSession(key, iv) + decrypted_data = session.decrypt(encrypted_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_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", ip, data) + raise KasaException( + 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 ): - return SmartDimmer + # 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"]) + except Exception as ex: + if debug_enabled: + data = ( + redact_data(info, NEW_DISCOVERY_REDACTORS) + if Discover._redact_data + else info + ) + _LOGGER.debug( + "Unable to parse discovery from device %s: %s", + config.host, + pf(data), + ) + raise UnsupportedDeviceError( + f"Unable to parse discovery from device: {config.host}: {ex}", + host=config.host, + ) from ex + + # Decrypt the data + if ( + encrypt_info := discovery_result.encrypt_info + ) and encrypt_info.sym_schm == "AES": + try: + Discover._decrypt_discovery_data(discovery_result) + except Exception: + _LOGGER.exception( + "Unable to decrypt discovery data %s: %s", + config.host, + redact_data(info, NEW_DISCOVERY_REDACTORS), + ) + type_ = discovery_result.device_type + try: + 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_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=conn_params.https) + ) is None: + _LOGGER.debug("Got unsupported device type: %s", type_) + raise UnsupportedDeviceError( + f"Unsupported device {config.host} of type {type_}: {info}", + discovery_result=discovery_result.to_dict(), + host=config.host, + ) + + if (protocol := get_protocol(config)) is None: + _LOGGER.debug( + "Got unsupported connection type: %s", config.connection_type.to_dict() + ) + raise UnsupportedDeviceError( + f"Unsupported encryption scheme {config.host} of " + + f"type {config.connection_type.to_dict()}: {info}", + discovery_result=discovery_result.to_dict(), + host=config.host, + ) + + if debug_enabled: + data = ( + redact_data(info, NEW_DISCOVERY_REDACTORS) + if Discover._redact_data + else info + ) + _LOGGER.debug("[DISCOVERY] %s << %s", config.host, pf(data)) + + device = device_class(config.host, protocol=protocol) + + di = discovery_result.to_dict() + di["model"], _, _ = discovery_result.device_model.partition("(") + device.update_from_discover_info(di) + return device + + +class _DiscoveryBaseMixin(DataClassJSONMixin): + """Base class for serialization mixin.""" + + class Config(BaseConfig): + """Serialization config.""" + + omit_none = True + omit_default = True + serialize_by_alias = True + - elif "smartplug" in type_.lower() and "children" in sysinfo: - return SmartStrip +@dataclass +class EncryptionScheme(_DiscoveryBaseMixin): + """Base model for encryption scheme of discovery result.""" - elif "smartplug" in type_.lower(): - if "children" in sysinfo: - return SmartStrip + is_support_https: bool + encrypt_type: str | None = None + http_port: int | None = None + lv: int | None = None - return SmartPlug - elif "smartbulb" in type_.lower(): - if "length" in sysinfo: # strips have length - return SmartLightStrip - return SmartBulb +@dataclass +class EncryptionInfo(_DiscoveryBaseMixin): + """Base model for encryption info of discovery result.""" - raise SmartDeviceException("Unknown device type: %s", type_) + sym_schm: str + key: str + data: str -if __name__ == "__main__": - logging.basicConfig(level=logging.INFO) - loop = asyncio.get_event_loop() +@dataclass +class DiscoveryResult(_DiscoveryBaseMixin): + """Base model for discovery result.""" - async def _on_device(dev): - await dev.update() - _LOGGER.info("Got device: %s", dev) + device_type: str + device_model: str + device_id: str + ip: str + mac: str + mgt_encrypt_schm: EncryptionScheme | None = None + device_name: str | None = None + encrypt_info: EncryptionInfo | None = None + encrypt_type: list[str] | None = None + decrypted_data: dict | None = None + is_reset_wifi: Annotated[bool | None, Alias("isResetWiFi")] = None - devices = loop.run_until_complete(Discover.discover(on_discovered=_on_device)) - for ip, dev in devices.items(): - print(f"[{ip}] {dev}") + firmware_version: str | None = None + hardware_version: str | None = None + hw_ver: str | None = None + owner: str | None = None + is_support_iot_cloud: bool | None = None + obd_src: str | None = None + factory_default: bool | None = None diff --git a/kasa/emeterstatus.py b/kasa/emeterstatus.py new file mode 100644 index 000000000..acb877894 --- /dev/null +++ b/kasa/emeterstatus.py @@ -0,0 +1,91 @@ +"""Module for emeter container.""" + +from __future__ import annotations + +import logging + +_LOGGER = logging.getLogger(__name__) + + +class EmeterStatus(dict): + """Container for converting different representations of emeter data. + + Newer FW/HW versions postfix the variable names with the used units, + where-as the olders do not have this feature. + + This class automatically converts between these two to allow + backwards and forwards compatibility. + """ + + @property + def voltage(self) -> float | None: + """Return voltage in V.""" + try: + return self["voltage"] + except ValueError: + return None + + @property + def power(self) -> float | None: + """Return power in W.""" + try: + return self["power"] + except ValueError: + return None + + @property + def current(self) -> float | None: + """Return current in A.""" + try: + return self["current"] + except ValueError: + return None + + @property + def total(self) -> float | None: + """Return total in kWh.""" + try: + return self["total"] + except ValueError: + return None + + def __repr__(self) -> str: + return ( + f"" + ) + + def __getitem__(self, item: str) -> float | None: + """Return value in wanted units.""" + valid_keys = [ + "voltage_mv", + "power_mw", + "current_ma", + "energy_wh", + "total_wh", + "voltage", + "power", + "current", + "total", + "energy", + ] + + # 1. if requested data is available, return it + if item in super().keys(): # noqa: SIM118 + return super().__getitem__(item) + # otherwise decide how to convert it + else: + if item not in valid_keys: + raise KeyError(item) + if "_" in item: # upscale + return super().__getitem__(item[: item.find("_")]) * 1000 + else: # downscale + for i in super().keys(): # noqa: SIM118 + if ( + i.startswith(item) + and (value := self.__getitem__(i)) is not None + ): + return value / 1000 + + _LOGGER.debug("Unable to find value for '%s'", item) + return None diff --git a/kasa/exceptions.py b/kasa/exceptions.py index 90d36c9a0..1c764ad7a 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -1,5 +1,202 @@ """python-kasa exceptions.""" +from __future__ import annotations -class SmartDeviceException(Exception): +from asyncio import TimeoutError as _asyncioTimeoutError +from enum import IntEnum +from functools import cache +from typing import Any + + +class KasaException(Exception): + """Base exception for library errors.""" + + +class TimeoutError(KasaException, _asyncioTimeoutError): + """Timeout exception for device errors.""" + + def __repr__(self) -> str: + return KasaException.__repr__(self) + + def __str__(self) -> str: + return KasaException.__str__(self) + + +class _ConnectionError(KasaException): + """Connection exception for device errors.""" + + +class UnsupportedDeviceError(KasaException): + """Exception for trying to connect to unsupported devices.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + self.discovery_result = kwargs.get("discovery_result") + self.host = kwargs.get("host") + super().__init__(*args) + + +class DeviceError(KasaException): """Base exception for device errors.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + self.error_code: SmartErrorCode | None = kwargs.get("error_code") + super().__init__(*args) + + def __repr__(self) -> str: + err_code = self.error_code.__repr__() if self.error_code else "" + return f"{self.__class__.__name__}({err_code})" + + def __str__(self) -> str: + err_code = f" (error_code={self.error_code.name})" if self.error_code else "" + return super().__str__() + err_code + + +class AuthenticationError(DeviceError): + """Base exception for device authentication errors.""" + + +class _RetryableError(DeviceError): + """Retryable exception for device errors.""" + + +class SmartErrorCode(IntEnum): + """Enum for SMART Error Codes.""" + + def __str__(self) -> str: + return f"{self.name}({self.value})" + + @staticmethod + @cache + def from_int(value: int) -> SmartErrorCode: + """Convert an integer to a SmartErrorCode.""" + return SmartErrorCode(value) + + SUCCESS = 0 + + # Transport Errors + SESSION_TIMEOUT_ERROR = 9999 + MULTI_REQUEST_FAILED_ERROR = 1200 + HTTP_TRANSPORT_FAILED_ERROR = 1112 + LOGIN_FAILED_ERROR = 1111 + HAND_SHAKE_FAILED_ERROR = 1100 + #: Real description unknown, seen after an encryption-changing fw upgrade + TRANSPORT_UNKNOWN_CREDENTIALS_ERROR = 1003 + TRANSPORT_NOT_AVAILABLE_ERROR = 1002 + CMD_COMMAND_CANCEL_ERROR = 1001 + NULL_TRANSPORT_ERROR = 1000 + + # Common Method Errors + COMMON_FAILED_ERROR = -1 + UNSPECIFIC_ERROR = -1001 + UNKNOWN_METHOD_ERROR = -1002 + JSON_DECODE_FAIL_ERROR = -1003 + JSON_ENCODE_FAIL_ERROR = -1004 + AES_DECODE_FAIL_ERROR = -1005 + REQUEST_LEN_ERROR_ERROR = -1006 + CLOUD_FAILED_ERROR = -1007 + PARAMS_ERROR = -1008 + INVALID_PUBLIC_KEY_ERROR = -1010 # Unverified + SESSION_PARAM_ERROR = -1101 + + # Method Specific Errors + QUICK_SETUP_ERROR = -1201 + DEVICE_ERROR = -1301 + DEVICE_NEXT_EVENT_ERROR = -1302 + FIRMWARE_ERROR = -1401 + FIRMWARE_VER_ERROR_ERROR = -1402 + LOGIN_ERROR = -1501 + TIME_ERROR = -1601 + TIME_SYS_ERROR = -1602 + TIME_SAVE_ERROR = -1603 + WIRELESS_ERROR = -1701 + WIRELESS_UNSUPPORTED_ERROR = -1702 + SCHEDULE_ERROR = -1801 + SCHEDULE_FULL_ERROR = -1802 + SCHEDULE_CONFLICT_ERROR = -1803 + SCHEDULE_SAVE_ERROR = -1804 + SCHEDULE_INDEX_ERROR = -1805 + COUNTDOWN_ERROR = -1901 + COUNTDOWN_CONFLICT_ERROR = -1902 + COUNTDOWN_SAVE_ERROR = -1903 + ANTITHEFT_ERROR = -2001 + ANTITHEFT_CONFLICT_ERROR = -2002 + ANTITHEFT_SAVE_ERROR = -2003 + ACCOUNT_ERROR = -2101 + STAT_ERROR = -2201 + STAT_SAVE_ERROR = -2202 + 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 + OUT_OF_LIMIT = -40406 + OTHER_ERROR = -40407 + SYSTEM_BLOCKED = -40408 + NONCE_EXPIRED = -40409 + FFS_NONE_PWD = -90000 + TIMEOUT_ERROR = 40108 + UNSUPPORTED_METHOD = -40106 + ONE_SECOND_REPEAT_REQUEST = -40109 + INVALID_NONCE = -40413 + PROTOCOL_FORMAT_ERROR = -40210 + IP_CONFLICT = -40321 + DIAGNOSE_TYPE_NOT_SUPPORT = -69051 + DIAGNOSE_TASK_FULL = -69052 + DIAGNOSE_TASK_BUSY = -69053 + DIAGNOSE_INTERNAL_ERROR = -69055 + DIAGNOSE_ID_NOT_FOUND = -69056 + DIAGNOSE_TASK_NULL = -69057 + CLOUD_LINK_DOWN = -69060 + ONVIF_SET_WRONG_TIME = -69061 + CLOUD_NTP_NO_RESPONSE = -69062 + CLOUD_GET_WRONG_TIME = -69063 + SNTP_SRV_NO_RESPONSE = -69064 + SNTP_GET_WRONG_TIME = -69065 + LINK_UNCONNECTED = -69076 + WIFI_SIGNAL_WEAK = -69077 + LOCAL_NETWORK_POOR = -69078 + CLOUD_NETWORK_POOR = -69079 + INTER_NETWORK_POOR = -69080 + DNS_TIMEOUT = -69081 + DNS_ERROR = -69082 + PING_NO_RESPONSE = -69083 + DHCP_MULTI_SERVER = -69084 + DHCP_ERROR = -69085 + STREAM_SESSION_CLOSE = -69094 + STREAM_BITRATE_EXCEPTION = -69095 + STREAM_FULL = -69096 + STREAM_NO_INTERNET = -69097 + HARDWIRED_NOT_FOUND = -72101 + + # Library internal for unknown error codes + INTERNAL_UNKNOWN_ERROR = -100_000 + # Library internal for query errors + INTERNAL_QUERY_ERROR = -100_001 + + +SMART_RETRYABLE_ERRORS = [ + SmartErrorCode.TRANSPORT_NOT_AVAILABLE_ERROR, + SmartErrorCode.HTTP_TRANSPORT_FAILED_ERROR, + SmartErrorCode.UNSPECIFIC_ERROR, + SmartErrorCode.SESSION_TIMEOUT_ERROR, + SmartErrorCode.SESSION_EXPIRED, + SmartErrorCode.INVALID_NONCE, +] + +SMART_AUTHENTICATION_ERRORS = [ + SmartErrorCode.LOGIN_ERROR, + SmartErrorCode.LOGIN_FAILED_ERROR, + SmartErrorCode.AES_DECODE_FAIL_ERROR, + SmartErrorCode.HAND_SHAKE_FAILED_ERROR, + SmartErrorCode.TRANSPORT_UNKNOWN_CREDENTIALS_ERROR, + SmartErrorCode.HOMEKIT_LOGIN_FAIL, +] diff --git a/kasa/feature.py b/kasa/feature.py new file mode 100644 index 000000000..0c4c6e230 --- /dev/null +++ b/kasa/feature.py @@ -0,0 +1,327 @@ +"""Interact with feature. + +Features are implemented by devices to represent individual pieces of functionality like +state, time, firmware. + +>>> from kasa import Discover, Module +>>> +>>> dev = await Discover.discover_single( +>>> "127.0.0.3", +>>> username="user@example.com", +>>> password="great_password" +>>> ) +>>> await dev.update() +>>> print(dev.alias) +Living Room Bulb + +Features allow for instrospection and can be interacted with as new features are added +to the API: + +>>> for feature_id, feature in dev.features.items(): +>>> 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# +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) +Color temperature (color_temperature): 2700 +Auto update enabled (auto_update_enabled): False +Update available (update_available): None +Current firmware version (current_firmware_version): 1.1.6 Build 240130 Rel.173828 +Available firmware version (available_firmware_version): None +Check latest firmware (check_latest_firmware): +Light effect (light_effect): Off +Light preset (light_preset): Not set +Smooth transition on (smooth_transition_on): 2 +Smooth transition off (smooth_transition_off): 2 +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) +100 + +You can update the value of a feature + +>>> await feature.set_value(50) +>>> await dev.update() +>>> print(feature.value) +50 + +Features have types that can be used for introspection: + +>>> feature = dev.features["light_preset"] +>>> print(feature.type) +Type.Choice + +>>> print(feature.choices) +['Not set', 'Light preset 1', 'Light preset 2', 'Light preset 3',\ + 'Light preset 4', 'Light preset 5', 'Light preset 6', 'Light preset 7'] +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from enum import Enum, auto +from functools import cached_property +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from .device import Device + from .module import Module + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class Feature: + """Feature defines a generic interface for device features.""" + + class Type(Enum): + """Type to help decide how to present the feature.""" + + #: Sensor is an informative read-only value + Sensor = auto() + #: BinarySensor is a read-only boolean + BinarySensor = auto() + #: Switch is a boolean setting + Switch = auto() + #: Action triggers some action on device + Action = auto() + #: Number defines a numeric setting + #: See :attr:`range_getter`, :attr:`Feature.minimum_value`, + #: and :attr:`maximum_value` + Number = auto() + #: Choice defines a setting with pre-defined values + Choice = auto() + Unknown = -1 + + # Aliases for easy access + Sensor = Type.Sensor + BinarySensor = Type.BinarySensor + Switch = Type.Switch + Action = Type.Action + Number = Type.Number + Choice = Type.Choice + + DEFAULT_MAX = 2**16 # Arbitrary max + + class Category(Enum): + """Category hint to allow feature grouping.""" + + #: Primary features control the device state directly. + #: Examples include turning the device on/off, or adjusting its brightness. + Primary = auto() + #: Config features change device behavior without immediate state changes. + Config = auto() + #: Informative/sensor features deliver some potentially interesting information. + Info = auto() + #: Debug features deliver more verbose information then informative features. + #: You may want to hide these per default to avoid cluttering your UI. + Debug = auto() + #: The default category if none is specified. + Unset = -1 + + #: Device instance required for getting and setting values + device: Device + #: Identifier + id: str + #: User-friendly short description + name: str + #: Type of the feature + type: Feature.Type + #: Callable or name of the property that allows accessing the value + attribute_getter: str | Callable | None = None + #: 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: Device | Module | None = None + #: Icon suggestion + icon: str | None = None + #: Attribute containing the name of the unit getter property. + #: If set, this property will be used to get the *unit*. + unit_getter: str | Callable[[], str] | None = None + #: Category hint for downstreams + category: Feature.Category = Category.Unset + + # Display hints offer a way suggest how the value should be shown to users + #: Hint to help rounding the sensor values to given after-comma digits + precision_hint: int | None = None + + #: Attribute containing the name of the range getter property. + #: If set, this property will be used to set *minimum_value* and *maximum_value*. + range_getter: str | Callable[[], tuple[int, int]] | None = None + + #: Attribute name of the choices getter property. + #: If set, this property will be used to get *choices*. + choices_getter: str | Callable[[], list[str]] | None = None + + def __post_init__(self) -> None: + """Handle late-binding of members.""" + # Populate minimum & maximum values, if range_getter is given + self._container = self.container if self.container is not None else self.device + + # Set the category, if unset + if self.category is Feature.Category.Unset: + if self.attribute_setter: + self.category = Feature.Category.Config + else: + self.category = Feature.Category.Info + + if self.type in ( + Feature.Type.Sensor, + Feature.Type.BinarySensor, + ): + if self.category == Feature.Category.Config: + raise ValueError( + f"Invalid type for configurable feature: {self.name} ({self.id}):" + f" {self.type}" + ) + elif self.attribute_setter is not None: + raise ValueError( + f"Read-only feat defines attribute_setter: {self.name} ({self.id}):" + ) + + def _get_property_value(self, getter: str | Callable | None) -> Any: + if getter is None: + return None + if isinstance(getter, str): + return getattr(self._container, getter) + if callable(getter): + return getter() + raise ValueError("Invalid getter: %s", getter) # pragma: no cover + + @property + def choices(self) -> list[str] | None: + """List of choices.""" + return self._get_property_value(self.choices_getter) + + @property + def unit(self) -> str | None: + """Unit if applicable.""" + return self._get_property_value(self.unit_getter) + + @cached_property + def range(self) -> tuple[int, int] | None: + """Range of values if applicable.""" + return self._get_property_value(self.range_getter) + + @property + def maximum_value(self) -> int: + """Maximum value.""" + if range := self.range: + return range[1] + return self.DEFAULT_MAX + + @property + def minimum_value(self) -> int: + """Minimum value.""" + if range := self.range: + return range[0] + return 0 + + @property + def value(self) -> int | float | bool | str | Enum | None: + """Return the current value.""" + if self.type == Feature.Type.Action: + return "" + if self.attribute_getter is None: + raise ValueError("Not an action and no attribute_getter set") + + container = self.container if self.container is not None else self.device + if callable(self.attribute_getter): + return self.attribute_getter(container) + return getattr(container, self.attribute_getter) + + async def set_value(self, value: int | float | bool | str | Enum | None) -> Any: + """Set the value.""" + if self.attribute_setter is None: + raise ValueError("Tried to set read-only feature.") + if self.type == Feature.Type.Number: # noqa: SIM102 + if not isinstance(value, int | float): + raise ValueError("value must be a number") + if value < self.minimum_value or value > self.maximum_value: + raise ValueError( + f"Value {value} out of range " + f"[{self.minimum_value}, {self.maximum_value}]" + ) + 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" - allowed: {self.choices}" + ) + + if callable(self.attribute_setter): + attribute_setter = self.attribute_setter + else: + container = self.container if self.container is not None else self.device + attribute_setter = getattr(container, self.attribute_setter) + + if self.type == Feature.Type.Action: + return await attribute_setter() + + return await attribute_setter(value) + + def __repr__(self) -> str: + try: + value = self.value + choices = self.choices + except Exception as ex: + return f"Unable to read value ({self.id}): {ex}" + + if self.type == Feature.Type.Choice: + 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, + self.id, + value, + choices, + ) + return ( + f"{self.name} ({self.id}): invalid value '{value}' not in {choices}" + ) + value = " ".join( + [ + 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 (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})" + + return s diff --git a/kasa/httpclient.py b/kasa/httpclient.py new file mode 100644 index 000000000..31d8dfbb6 --- /dev/null +++ b/kasa/httpclient.py @@ -0,0 +1,177 @@ +"""Module for HttpClientSession class.""" + +from __future__ import annotations + +import asyncio +import logging +import ssl +import time +from typing import Any + +import aiohttp +from yarl import URL + +from .deviceconfig import DeviceConfig +from .exceptions import ( + KasaException, + TimeoutError, + _ConnectionError, +) +from .json import loads as json_loads + +_LOGGER = logging.getLogger(__name__) + + +def get_cookie_jar() -> aiohttp.CookieJar: + """Return a new cookie jar with the correct options for device communication.""" + return aiohttp.CookieJar(unsafe=True, quote_cookie=False) + + +class HttpClient: + """HttpClient Class.""" + + # Some devices (only P100 so far) close the http connection after each request + # and aiohttp doesn't seem to handle it. If a Client OS error is received the + # http client will start ensuring that sequential requests have a wait delay. + WAIT_BETWEEN_REQUESTS_ON_OSERROR = 0.25 + + def __init__(self, config: DeviceConfig) -> None: + self._config = config + self._client_session: aiohttp.ClientSession | None = None + self._jar = aiohttp.CookieJar(unsafe=True, quote_cookie=False) + self._last_url = URL(f"http://{self._config.host}/") + + self._wait_between_requests = 0.0 + self._last_request_time = 0.0 + + @property + def client(self) -> aiohttp.ClientSession: + """Return the underlying http client.""" + if self._config.http_client and issubclass( + self._config.http_client.__class__, aiohttp.ClientSession + ): + return self._config.http_client + + if not self._client_session: + self._client_session = aiohttp.ClientSession(cookie_jar=get_cookie_jar()) + return self._client_session + + async def post( + self, + url: URL, + *, + params: dict[str, Any] | None = None, + data: bytes | None = None, + json: dict | Any | None = None, + headers: dict[str, str] | None = None, + cookies_dict: dict[str, str] | None = None, + ssl: ssl.SSLContext | bool = False, + ) -> tuple[int, dict | bytes | None]: + """Send an http post request to the device. + + If the request is provided via the json parameter json will be returned. + """ + # Once we know a device needs a wait between sequential queries always wait + # first rather than keep erroring then waiting. + if self._wait_between_requests: + now = time.monotonic() + gap = now - self._last_request_time + if gap < self._wait_between_requests: + sleep = self._wait_between_requests - gap + _LOGGER.debug( + "Device %s waiting %s seconds to send request", + self._config.host, + sleep, + ) + await asyncio.sleep(sleep) + + _LOGGER.debug("Posting to %s", url) + response_data = None + self._last_url = url + self.client.cookie_jar.clear() + return_json = bool(json) + if self._config.timeout is None: + _LOGGER.warning("Request timeout is set to None.") + client_timeout = aiohttp.ClientTimeout(total=self._config.timeout) + + # If json is not a dict send as data. + # This allows the json parameter to be used to pass other + # types of data such as async_generator and still have json + # returned. + if json and not isinstance(json, dict): + data = json + json = None + try: + resp = await self.client.post( + url, + params=params, + data=data, + json=json, + timeout=client_timeout, + cookies=cookies_dict, + headers=headers, + ssl=ssl, + ) + async with resp: + 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: + _LOGGER.debug( + "Device %s received an os error, " + "enabling sequential request delay: %s", + self._config.host, + ex, + ) + self._wait_between_requests = self.WAIT_BETWEEN_REQUESTS_ON_OSERROR + self._last_request_time = time.monotonic() + raise _ConnectionError( + f"Device connection error: {self._config.host}: {ex}", ex + ) from ex + except (aiohttp.ServerTimeoutError, TimeoutError) as ex: + raise TimeoutError( + "Unable to query the device, " + + f"timed out: {self._config.host}: {ex}", + ex, + ) from ex + except Exception as ex: + raise KasaException( + f"Unable to query the device: {self._config.host}: {ex}", ex + ) from ex + + # For performance only request system time if waiting is enabled + if self._wait_between_requests: + self._last_request_time = time.monotonic() + + return resp.status, response_data + + def get_cookie(self, cookie_name: str) -> str | None: + """Return the cookie with cookie_name.""" + if cookie := self.client.cookie_jar.filter_cookies(self._last_url).get( + cookie_name + ): + return cookie.value + return None + + async def close(self) -> None: + """Close the ClientSession.""" + client = self._client_session + self._client_session = None + if client: + await client.close() diff --git a/kasa/interfaces/__init__.py b/kasa/interfaces/__init__.py new file mode 100644 index 000000000..ac5e00da0 --- /dev/null +++ b/kasa/interfaces/__init__.py @@ -0,0 +1,27 @@ +"""Package for interfaces.""" + +from .alarm import Alarm +from .childsetup import ChildSetup +from .energy import Energy +from .fan import Fan +from .led import Led +from .light import Light, LightState +from .lighteffect import LightEffect +from .lightpreset import LightPreset +from .thermostat import Thermostat, ThermostatState +from .time import Time + +__all__ = [ + "Alarm", + "ChildSetup", + "Fan", + "Energy", + "Led", + "Light", + "LightEffect", + "LightState", + "LightPreset", + "Thermostat", + "ThermostatState", + "Time", +] 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..f91a8383c --- /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 new file mode 100644 index 000000000..b6cc203fa --- /dev/null +++ b/kasa/interfaces/energy.py @@ -0,0 +1,194 @@ +"""Module for base energy module.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from enum import IntFlag, auto +from typing import TYPE_CHECKING, Any +from warnings import warn + +from ..emeterstatus import EmeterStatus +from ..feature import Feature +from ..module import Module + + +class Energy(Module, ABC): + """Base interface to represent an Energy module.""" + + class ModuleFeature(IntFlag): + """Features supported by the device.""" + + #: Device reports :attr:`voltage` and :attr:`current` + VOLTAGE_CURRENT = auto() + #: Device reports :attr:`consumption_total` + CONSUMPTION_TOTAL = auto() + #: Device reports periodic stats via :meth:`get_daily_stats` + #: and :meth:`get_monthly_stats` + PERIODIC_STATS = auto() + + _supported: ModuleFeature = ModuleFeature(0) + + def supports(self, module_feature: Energy.ModuleFeature) -> bool: + """Return True if module supports the feature.""" + return module_feature in self._supported + + def _initialize_features(self) -> None: + """Initialize features.""" + device = self._device + self._add_feature( + Feature( + device, + name="Current consumption", + attribute_getter="current_consumption", + container=self, + unit_getter=lambda: "W", + id="current_consumption", + precision_hint=1, + category=Feature.Category.Primary, + type=Feature.Type.Sensor, + ) + ) + self._add_feature( + Feature( + device, + name="Today's consumption", + attribute_getter="consumption_today", + container=self, + unit_getter=lambda: "kWh", + id="consumption_today", + precision_hint=3, + category=Feature.Category.Info, + type=Feature.Type.Sensor, + ) + ) + self._add_feature( + Feature( + device, + id="consumption_this_month", + name="This month's consumption", + attribute_getter="consumption_this_month", + container=self, + unit_getter=lambda: "kWh", + precision_hint=3, + category=Feature.Category.Info, + type=Feature.Type.Sensor, + ) + ) + if self.supports(self.ModuleFeature.CONSUMPTION_TOTAL): + self._add_feature( + Feature( + device, + name="Total consumption since reboot", + attribute_getter="consumption_total", + container=self, + unit_getter=lambda: "kWh", + id="consumption_total", + precision_hint=3, + category=Feature.Category.Info, + type=Feature.Type.Sensor, + ) + ) + if self.supports(self.ModuleFeature.VOLTAGE_CURRENT): + self._add_feature( + Feature( + device, + name="Voltage", + attribute_getter="voltage", + container=self, + unit_getter=lambda: "V", + id="voltage", + precision_hint=1, + category=Feature.Category.Primary, + type=Feature.Type.Sensor, + ) + ) + self._add_feature( + Feature( + device, + name="Current", + attribute_getter="current", + container=self, + unit_getter=lambda: "A", + id="current", + precision_hint=2, + category=Feature.Category.Primary, + type=Feature.Type.Sensor, + ) + ) + + @property + @abstractmethod + def status(self) -> EmeterStatus: + """Return current energy readings.""" + + @property + @abstractmethod + def current_consumption(self) -> float | None: + """Get the current power consumption in Watt.""" + + @property + @abstractmethod + def consumption_today(self) -> float | None: + """Return today's energy consumption in kWh.""" + + @property + @abstractmethod + def consumption_this_month(self) -> float | None: + """Return this month's energy consumption in kWh.""" + + @property + @abstractmethod + def consumption_total(self) -> float | None: + """Return total consumption since last reboot in kWh.""" + + @property + @abstractmethod + def current(self) -> float | None: + """Return the current in A.""" + + @property + @abstractmethod + def voltage(self) -> float | None: + """Get the current voltage in V.""" + + @abstractmethod + async def get_status(self) -> EmeterStatus: + """Return real-time statistics.""" + + @abstractmethod + async def erase_stats(self) -> dict: + """Erase all stats.""" + + @abstractmethod + async def get_daily_stats( + self, *, year: int | None = None, month: int | None = None, kwh: bool = True + ) -> dict: + """Return daily stats for the given year & month. + + The return value is a dictionary of {day: energy, ...}. + """ + + @abstractmethod + async def get_monthly_stats( + self, *, year: int | None = None, kwh: bool = True + ) -> dict: + """Return monthly stats for the given year.""" + + _deprecated_attributes = { + "emeter_today": "consumption_today", + "emeter_this_month": "consumption_this_month", + "realtime": "status", + "get_realtime": "get_status", + "erase_emeter_stats": "erase_stats", + "get_daystat": "get_daily_stats", + "get_monthstat": "get_monthly_stats", + } + + if not TYPE_CHECKING: + + def __getattr__(self, name: str) -> Any: + if attr := self._deprecated_attributes.get(name): + msg = f"{name} is deprecated, use {attr} instead" + warn(msg, DeprecationWarning, stacklevel=2) + return getattr(self, attr) + raise AttributeError(f"Energy module has no attribute {name!r}") diff --git a/kasa/interfaces/fan.py b/kasa/interfaces/fan.py new file mode 100644 index 000000000..9462ad882 --- /dev/null +++ b/kasa/interfaces/fan.py @@ -0,0 +1,23 @@ +"""Module for Fan Interface.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Annotated + +from ..module import FeatureAttribute, Module + + +class Fan(Module, ABC): + """Interface for a Fan.""" + + @property + @abstractmethod + def fan_speed_level(self) -> Annotated[int, FeatureAttribute()]: + """Return fan speed level.""" + + @abstractmethod + async def set_fan_speed_level( + self, level: int + ) -> Annotated[dict, FeatureAttribute()]: + """Set fan speed level.""" diff --git a/kasa/interfaces/led.py b/kasa/interfaces/led.py new file mode 100644 index 000000000..2d34597bb --- /dev/null +++ b/kasa/interfaces/led.py @@ -0,0 +1,38 @@ +"""Module for base light effect module.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from ..feature import Feature +from ..module import Module + + +class Led(Module, ABC): + """Base interface to represent a LED module.""" + + def _initialize_features(self) -> None: + """Initialize features.""" + device = self._device + self._add_feature( + Feature( + device=device, + container=self, + name="LED", + id="led", + icon="mdi:led", + attribute_getter="led", + attribute_setter="set_led", + type=Feature.Type.Switch, + category=Feature.Category.Config, + ) + ) + + @property + @abstractmethod + def led(self) -> bool: + """Return current led status.""" + + @abstractmethod + async def set_led(self, enable: bool) -> dict: + """Set led.""" diff --git a/kasa/interfaces/light.py b/kasa/interfaces/light.py new file mode 100644 index 000000000..fdcfe46dc --- /dev/null +++ b/kasa/interfaces/light.py @@ -0,0 +1,214 @@ +"""Interact with a TPLink Light. + +>>> from kasa import Discover, Module +>>> +>>> dev = await Discover.discover_single( +>>> "127.0.0.3", +>>> username="user@example.com", +>>> password="great_password" +>>> ) +>>> await dev.update() +>>> print(dev.alias) +Living Room Bulb + +Lights, like any other supported devices, can be turned on and off: + +>>> print(dev.is_on) +>>> await dev.turn_on() +>>> await dev.update() +>>> print(dev.is_on) +True + +Get the light module to interact: + +>>> light = dev.modules[Module.Light] + +You can use the ``has_feature()`` method to check for supported features: + +>>> light.has_feature("brightness") +True +>>> light.has_feature("hsv") +True +>>> light.has_feature("color_temp") +True + +All known bulbs support changing the brightness: + +>>> light.brightness +100 +>>> await light.set_brightness(50) +>>> await dev.update() +>>> light.brightness +50 + +Bulbs supporting color temperature can be queried for the supported range: + +>>> 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 +3000 + +Color bulbs can be adjusted by passing hue, saturation and value: + +>>> await light.set_hsv(180, 100, 80) +>>> await dev.update() +>>> light.hsv +HSV(hue=180, saturation=100, value=80) + + +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import TYPE_CHECKING, Annotated, Any, NamedTuple +from warnings import warn + +from ..exceptions import KasaException +from ..module import FeatureAttribute, Module + + +@dataclass +class LightState: + """Class for smart light preset info.""" + + light_on: bool | None = None + brightness: int | None = None + hue: int | None = None + saturation: int | None = None + color_temp: int | None = None + transition: int | None = None + + +class ColorTempRange(NamedTuple): + """Color temperature range.""" + + min: int + max: int + + +class HSV(NamedTuple): + """Hue-saturation-value.""" + + hue: int + saturation: int + value: int + + +class Light(Module, ABC): + """Base class for TP-Link Light.""" + + @property + @abstractmethod + def hsv(self) -> Annotated[HSV, FeatureAttribute()]: + """Return the current HSV state of the bulb. + + :return: hue, saturation and value (degrees, %, %) + """ + + @property + @abstractmethod + def color_temp(self) -> Annotated[int, FeatureAttribute()]: + """Whether the bulb supports color temperature changes.""" + + @property + @abstractmethod + def brightness(self) -> Annotated[int, FeatureAttribute()]: + """Return the current brightness in percentage.""" + + @abstractmethod + async def set_hsv( + self, + hue: int, + saturation: int, + value: int | None = None, + *, + transition: int | None = None, + ) -> Annotated[dict, FeatureAttribute()]: + """Set new HSV. + + Note, transition is not supported and will be ignored. + + :param int hue: hue in degrees + :param int saturation: saturation in percentage [0,100] + :param int value: value in percentage [0, 100] + :param int transition: transition in milliseconds. + """ + + @abstractmethod + async def set_color_temp( + self, temp: int, *, brightness: int | None = None, transition: int | None = None + ) -> Annotated[dict, FeatureAttribute()]: + """Set the color temperature of the device in kelvin. + + Note, transition is not supported and will be ignored. + + :param int temp: The new color temperature, in Kelvin + :param int transition: transition in milliseconds. + """ + + @abstractmethod + async def set_brightness( + self, brightness: int, *, transition: int | None = None + ) -> Annotated[dict, FeatureAttribute()]: + """Set the brightness in percentage. + + Note, transition is not supported and will be ignored. + + :param int brightness: brightness in percent + :param int transition: transition in milliseconds. + """ + + @property + @abstractmethod + def state(self) -> LightState: + """Return the current light state.""" + + @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 new file mode 100644 index 000000000..bfcd9be36 --- /dev/null +++ b/kasa/interfaces/lighteffect.py @@ -0,0 +1,121 @@ +"""Interact with a TPLink Light Effect. + +>>> from kasa import Discover, Module, LightState +>>> +>>> dev = await Discover.discover_single( +>>> "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 + +>>> light_effect = dev.modules[Module.LightEffect] +>>> light_effect.effect_list +['Off', 'Party', 'Relax'] + +To view the currently selected effect: + +>>> light_effect.effect +Off + +To activate a light effect: + +>>> await light_effect.set_effect("Party") +>>> await dev.update() +>>> light_effect.effect +Party + +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) +>>> light_effect.has_custom_effects # The device in this examples does not support \ +custom effects +False +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from ..feature import Feature +from ..module import Module + + +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.""" + device = self._device + self._add_feature( + Feature( + device, + id="light_effect", + name="Light effect", + container=self, + attribute_getter="effect", + attribute_setter="set_effect", + category=Feature.Category.Primary, + type=Feature.Type.Choice, + choices_getter="effect_list", + ) + ) + + @property + @abstractmethod + def has_custom_effects(self) -> bool: + """Return True if the device supports setting custom effects.""" + + @property + @abstractmethod + def effect(self) -> str: + """Return effect name.""" + + @property + @abstractmethod + def effect_list(self) -> list[str]: + """Return built-in effects list. + + Example: + ['Aurora', 'Bubbling Cauldron', ...] + """ + + @abstractmethod + async def set_effect( + self, + effect: str, + *, + brightness: int | None = None, + transition: int | None = None, + ) -> dict: + """Set an effect on the device. + + If brightness or transition is defined, + its value will be used instead of the effect-specific default. + + See :meth:`effect_list` for available effects, + or use :meth:`set_custom_effect` for custom effects. + + :param str effect: The effect to set + :param int brightness: The wanted brightness + :param int transition: The wanted transition time + """ + + @abstractmethod + async def set_custom_effect( + self, + effect_dict: dict, + ) -> dict: + """Set a custom effect on the device. + + :param str effect_dict: The custom effect dict to set + """ diff --git a/kasa/interfaces/lightpreset.py b/kasa/interfaces/lightpreset.py new file mode 100644 index 000000000..586671e70 --- /dev/null +++ b/kasa/interfaces/lightpreset.py @@ -0,0 +1,144 @@ +"""Interact with TPLink Light Presets. + +>>> from kasa import Discover, Module, LightState +>>> +>>> dev = await Discover.discover_single( +>>> "127.0.0.3", +>>> username="user@example.com", +>>> password="great_password" +>>> ) +>>> await dev.update() +>>> print(dev.alias) +Living Room Bulb + +Light presets are accessed via the LightPreset module. To list available presets + +>>> light_preset = dev.modules[Module.LightPreset] +>>> light_preset.preset_list +['Not set', 'Light preset 1', 'Light preset 2', 'Light preset 3',\ + 'Light preset 4', 'Light preset 5', 'Light preset 6', 'Light preset 7'] + +To view the currently selected preset: + +>>> light_preset.preset +Not set + +To view the actual light state for the presets: + +>>> len(light_preset.preset_states_list) +7 + +>>> light_preset.preset_states_list[0] +LightState(light_on=None, brightness=50, hue=0,\ + saturation=100, color_temp=2700, transition=None) + +To set a preset as active: + +>>> dev.modules[Module.Light].state # This is only needed to show the example working +LightState(light_on=True, brightness=100, hue=0,\ + saturation=100, color_temp=2700, transition=None) +>>> await light_preset.set_preset("Light preset 1") +>>> await dev.update() +>>> light_preset.preset +Light preset 1 +>>> dev.modules[Module.Light].state # This is only needed to show the example working +LightState(light_on=True, brightness=50, hue=0,\ + saturation=100, color_temp=2700, transition=None) + +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,\ + saturation=100, color_temp=2700, transition=None) +>>> 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 +Not set +>>> light_preset.preset_states_list[0] +LightState(light_on=None, brightness=75, hue=0,\ + saturation=100, color_temp=2700, transition=None) + +If you manually set the light state to a preset state it will show that preset as \ + active: + +>>> await dev.modules[Module.Light].set_brightness(75) +>>> await dev.update() +>>> light_preset.preset +Light preset 1 +""" + +from __future__ import annotations + +from abc import abstractmethod +from collections.abc import Sequence + +from ..feature import Feature +from ..module import Module +from .light import LightState + + +class LightPreset(Module): + """Base interface for light preset module.""" + + PRESET_NOT_SET = "Not set" + + def _initialize_features(self) -> None: + """Initialize features.""" + device = self._device + self._add_feature( + Feature( + device, + id="light_preset", + name="Light preset", + container=self, + attribute_getter="preset", + attribute_setter="set_preset", + category=Feature.Category.Config, + type=Feature.Type.Choice, + choices_getter="preset_list", + ) + ) + + @property + @abstractmethod + def preset_list(self) -> list[str]: + """Return list of preset names. + + Example: + ['Off', 'Preset 1', 'Preset 2', ...] + """ + + @property + @abstractmethod + def preset_states_list(self) -> Sequence[LightState]: + """Return list of preset states. + + Example: + ['Off', 'Preset 1', 'Preset 2', ...] + """ + + @property + @abstractmethod + def preset(self) -> str: + """Return current preset name.""" + + @abstractmethod + async def set_preset( + self, + preset_name: str, + ) -> dict: + """Set a light preset for the device.""" + + @abstractmethod + async def save_preset( + self, + preset_name: str, + preset_info: LightState, + ) -> dict: + """Update the preset with *preset_name* with the new *preset_info*.""" + + @property + @abstractmethod + def has_save_preset(self) -> bool: + """Return True if the device supports updating presets.""" diff --git a/kasa/interfaces/thermostat.py b/kasa/interfaces/thermostat.py new file mode 100644 index 000000000..1d2ed28b2 --- /dev/null +++ b/kasa/interfaces/thermostat.py @@ -0,0 +1,67 @@ +"""Interact with a TPLink Thermostat.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from enum import Enum +from typing import Annotated, Literal + +from ..module import FeatureAttribute, Module + + +class ThermostatState(Enum): + """Thermostat state.""" + + Heating = "heating" + Calibrating = "progress_calibration" + Idle = "idle" + Hold = "hold_on" + Off = "off" + Shutdown = "shutdown" + Unknown = "unknown" + + +class Thermostat(Module, ABC): + """Base class for TP-Link Thermostat.""" + + @property + @abstractmethod + def state(self) -> bool: + """Return thermostat state.""" + + @abstractmethod + async def set_state(self, enabled: bool) -> dict: + """Set thermostat state.""" + + @property + @abstractmethod + def mode(self) -> ThermostatState: + """Return thermostat state.""" + + @property + @abstractmethod + def target_temperature(self) -> Annotated[float, FeatureAttribute()]: + """Return target temperature.""" + + @abstractmethod + async def set_target_temperature( + self, target: float + ) -> Annotated[dict, FeatureAttribute()]: + """Set target temperature.""" + + @property + @abstractmethod + def temperature(self) -> Annotated[float, FeatureAttribute()]: + """Return current humidity in percentage.""" + return self._device.sys_info["current_temp"] + + @property + @abstractmethod + def temperature_unit(self) -> Literal["celsius", "fahrenheit"]: + """Return current temperature unit.""" + + @abstractmethod + async def set_temperature_unit( + self, unit: Literal["celsius", "fahrenheit"] + ) -> dict: + """Set the device temperature unit.""" diff --git a/kasa/interfaces/time.py b/kasa/interfaces/time.py new file mode 100644 index 000000000..2659b3b3d --- /dev/null +++ b/kasa/interfaces/time.py @@ -0,0 +1,26 @@ +"""Module for time interface.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from datetime import datetime, tzinfo + +from ..module import Module + + +class Time(Module, ABC): + """Base class for tplink time module.""" + + @property + @abstractmethod + def time(self) -> datetime: + """Return timezone aware current device time.""" + + @property + @abstractmethod + def timezone(self) -> tzinfo: + """Return current timezone.""" + + @abstractmethod + async def set_time(self, dt: datetime) -> dict: + """Set the device time.""" diff --git a/kasa/iot/__init__.py b/kasa/iot/__init__.py new file mode 100644 index 000000000..3b5b01c64 --- /dev/null +++ b/kasa/iot/__init__.py @@ -0,0 +1,20 @@ +"""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 +from .iotplug import IotPlug, IotWallSwitch +from .iotstrip import IotStrip + +__all__ = [ + "IotDevice", + "IotPlug", + "IotBulb", + "IotStrip", + "IotDimmer", + "IotLightStrip", + "IotWallSwitch", + "IotCamera", +] diff --git a/kasa/iot/effects.py b/kasa/iot/effects.py new file mode 100644 index 000000000..8b3e7b329 --- /dev/null +++ b/kasa/iot/effects.py @@ -0,0 +1,298 @@ +"""Module for light strip effects (LB*, KL*, KB*).""" + +from __future__ import annotations + +from typing import cast + +EFFECT_AURORA = { + "custom": 0, + "id": "xqUxDhbAhNLqulcuRMyPBmVGyTOyEMEu", + "brightness": 100, + "name": "Aurora", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "type": "sequence", + "duration": 0, + "transition": 1500, + "direction": 4, + "spread": 7, + "repeat_times": 0, + "sequence": [[120, 100, 100], [240, 100, 100], [260, 100, 100], [280, 100, 100]], +} +EFFECT_BUBBLING_CAULDRON = { + "custom": 0, + "id": "tIwTRQBqJpeNKbrtBMFCgkdPTbAQGfRP", + "brightness": 100, + "name": "Bubbling Cauldron", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "type": "random", + "hue_range": [100, 270], + "saturation_range": [80, 100], + "brightness_range": [50, 100], + "duration": 0, + "transition": 200, + "init_states": [[270, 100, 100]], + "fadeoff": 1000, + "random_seed": 24, + "backgrounds": [[270, 40, 50]], +} +EFFECT_CANDY_CANE = { + "custom": 0, + "id": "HCOttllMkNffeHjEOLEgrFJjbzQHoxEJ", + "brightness": 100, + "name": "Candy Cane", + "segments": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + "expansion_strategy": 1, + "enable": 1, + "type": "sequence", + "duration": 700, + "transition": 500, + "direction": 1, + "spread": 1, + "repeat_times": 0, + "sequence": [ + [0, 0, 100], + [0, 0, 100], + [360, 81, 100], + [0, 0, 100], + [0, 0, 100], + [360, 81, 100], + [360, 81, 100], + [0, 0, 100], + [0, 0, 100], + [360, 81, 100], + [360, 81, 100], + [360, 81, 100], + [360, 81, 100], + [0, 0, 100], + [0, 0, 100], + [360, 81, 100], + ], +} +EFFECT_CHRISTMAS = { + "custom": 0, + "id": "bwTatyinOUajKrDwzMmqxxJdnInQUgvM", + "brightness": 100, + "name": "Christmas", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "type": "random", + "hue_range": [136, 146], + "saturation_range": [90, 100], + "brightness_range": [50, 100], + "duration": 5000, + "transition": 0, + "init_states": [[136, 0, 100]], + "fadeoff": 2000, + "random_seed": 100, + "backgrounds": [[136, 98, 75], [136, 0, 0], [350, 0, 100], [350, 97, 94]], +} +EFFECT_FLICKER = { + "custom": 0, + "id": "bCTItKETDFfrKANolgldxfgOakaarARs", + "brightness": 100, + "name": "Flicker", + "segments": [1], + "expansion_strategy": 1, + "enable": 1, + "type": "random", + "hue_range": [30, 40], + "saturation_range": [100, 100], + "brightness_range": [50, 100], + "duration": 0, + "transition": 0, + "transition_range": [375, 500], + "init_states": [[30, 81, 80]], +} +EFFECT_HANUKKAH = { + "custom": 0, + "id": "CdLeIgiKcQrLKMINRPTMbylATulQewLD", + "brightness": 100, + "name": "Hanukkah", + "segments": [1], + "expansion_strategy": 1, + "enable": 1, + "type": "random", + "hue_range": [200, 210], + "saturation_range": [0, 100], + "brightness_range": [50, 100], + "duration": 1500, + "transition": 0, + "transition_range": [400, 500], + "init_states": [[35, 81, 80]], +} +EFFECT_HAUNTED_MANSION = { + "custom": 0, + "id": "oJnFHsVQzFUTeIOBAhMRfVeujmSauhjJ", + "brightness": 80, + "name": "Haunted Mansion", + "segments": [80], + "expansion_strategy": 2, + "enable": 1, + "type": "random", + "hue_range": [45, 45], + "saturation_range": [10, 10], + "brightness_range": [0, 80], + "duration": 0, + "transition": 0, + "transition_range": [50, 1500], + "init_states": [[45, 10, 100]], + "fadeoff": 200, + "random_seed": 1, + "backgrounds": [[45, 10, 100]], +} +EFFECT_ICICLE = { + "custom": 0, + "id": "joqVjlaTsgzmuQQBAlHRkkPAqkBUiqeb", + "brightness": 70, + "name": "Icicle", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "type": "sequence", + "duration": 0, + "transition": 400, + "direction": 4, + "spread": 3, + "repeat_times": 0, + "sequence": [ + [190, 100, 70], + [190, 100, 70], + [190, 30, 50], + [190, 100, 70], + [190, 100, 70], + ], +} +EFFECT_LIGHTNING = { + "custom": 0, + "id": "ojqpUUxdGHoIugGPknrUcRoyJiItsjuE", + "brightness": 100, + "name": "Lightning", + "segments": [7, 20, 23, 32, 34, 35, 49, 65, 66, 74, 80], + "expansion_strategy": 1, + "enable": 1, + "type": "random", + "hue_range": [240, 240], + "saturation_range": [10, 11], + "brightness_range": [90, 100], + "duration": 0, + "transition": 50, + "init_states": [[240, 30, 100]], + "fadeoff": 150, + "random_seed": 600, + "backgrounds": [[200, 100, 100], [200, 50, 10], [210, 10, 50], [240, 10, 0]], +} +EFFECT_OCEAN = { + "custom": 0, + "id": "oJjUMosgEMrdumfPANKbkFmBcAdEQsPy", + "brightness": 30, + "name": "Ocean", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "type": "sequence", + "duration": 0, + "transition": 2000, + "direction": 3, + "spread": 16, + "repeat_times": 0, + "sequence": [[198, 84, 30], [198, 70, 30], [198, 10, 30]], +} +EFFECT_RAINBOW = { + "custom": 0, + "id": "izRhLCQNcDzIKdpMPqSTtBMuAIoreAuT", + "brightness": 100, + "name": "Rainbow", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "type": "sequence", + "duration": 0, + "transition": 1500, + "direction": 1, + "spread": 12, + "repeat_times": 0, + "sequence": [[0, 100, 100], [100, 100, 100], [200, 100, 100], [300, 100, 100]], +} +EFFECT_RAINDROP = { + "custom": 0, + "id": "QbDFwiSFmLzQenUOPnJrsGqyIVrJrRsl", + "brightness": 30, + "name": "Raindrop", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "type": "random", + "hue_range": [200, 200], + "saturation_range": [10, 20], + "brightness_range": [10, 30], + "duration": 0, + "transition": 1000, + "init_states": [[200, 40, 100]], + "fadeoff": 1000, + "random_seed": 24, + "backgrounds": [[200, 40, 0]], +} +EFFECT_SPRING = { + "custom": 0, + "id": "URdUpEdQbnOOechDBPMkKrwhSupLyvAg", + "brightness": 100, + "name": "Spring", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "type": "random", + "hue_range": [0, 90], + "saturation_range": [30, 100], + "brightness_range": [90, 100], + "duration": 600, + "transition": 0, + "transition_range": [2000, 6000], + "init_states": [[80, 30, 100]], + "fadeoff": 1000, + "random_seed": 20, + "backgrounds": [[130, 100, 40]], +} +EFFECT_VALENTINES = { + "custom": 0, + "id": "QglBhMShPHUAuxLqzNEefFrGiJwahOmz", + "brightness": 100, + "name": "Valentines", + "segments": [0], + "expansion_strategy": 1, + "enable": 1, + "type": "random", + "hue_range": [340, 340], + "saturation_range": [30, 40], + "brightness_range": [90, 100], + "duration": 600, + "transition": 2000, + "init_states": [[340, 30, 100]], + "fadeoff": 3000, + "random_seed": 100, + "backgrounds": [[340, 20, 50], [20, 50, 50], [0, 100, 50]], +} + +EFFECTS_LIST_V1 = [ + EFFECT_AURORA, + EFFECT_BUBBLING_CAULDRON, + EFFECT_CANDY_CANE, + EFFECT_CHRISTMAS, + EFFECT_FLICKER, + EFFECT_HANUKKAH, + EFFECT_HAUNTED_MANSION, + EFFECT_ICICLE, + EFFECT_LIGHTNING, + EFFECT_OCEAN, + EFFECT_RAINBOW, + EFFECT_RAINDROP, + EFFECT_SPRING, + EFFECT_VALENTINES, +] + +EFFECT_NAMES_V1: list[str] = [cast(str, effect["name"]) for effect in EFFECTS_LIST_V1] +EFFECT_MAPPING_V1 = {effect["name"]: effect for effect in EFFECTS_LIST_V1} diff --git a/kasa/iot/iotbulb.py b/kasa/iot/iotbulb.py new file mode 100644 index 000000000..cb2e858cd --- /dev/null +++ b/kasa/iot/iotbulb.py @@ -0,0 +1,534 @@ +"""Module for bulbs (LB*, KL*, KB*).""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass +from enum import Enum +from typing import Annotated, cast + +from mashumaro import DataClassDictMixin +from mashumaro.config import BaseConfig +from mashumaro.types import Alias + +from ..device_type import DeviceType +from ..deviceconfig import DeviceConfig +from ..interfaces.light import HSV, ColorTempRange +from ..module import Module +from ..protocols import BaseProtocol +from .iotdevice import IotDevice, KasaException, requires_update +from .modules import ( + Antitheft, + Cloud, + Countdown, + Emeter, + Light, + LightPreset, + Schedule, + Time, + Usage, +) + + +class BehaviorMode(str, Enum): + """Enum to present type of turn on behavior.""" + + #: Return to the last state known state. + Last = "last_status" + #: Use chosen preset. + Preset = "customize_preset" + #: Circadian + Circadian = "circadian" + + +@dataclass +class TurnOnBehavior(DataClassDictMixin): + """Model to present a single turn on behavior. + + :param int preset: the index number of wanted preset. + :param BehaviorMode mode: last status or preset mode. + If you are changing existing settings, you should not set this manually. + + To change the behavior, it is only necessary to change the :attr:`preset` field + to contain either the preset index, or ``None`` for the last known state. + """ + + class Config(BaseConfig): + """Serialization config.""" + + omit_none = True + serialize_by_alias = True + + #: Wanted behavior + mode: BehaviorMode + #: Index of preset to use, or ``None`` for the last known state. + preset: Annotated[int | None, Alias("index")] = None + brightness: int | None = None + color_temp: int | None = None + hue: int | None = None + saturation: int | None = None + + +@dataclass +class TurnOnBehaviors(DataClassDictMixin): + """Model to contain turn on behaviors.""" + + #: The behavior when the bulb is turned on programmatically. + soft: Annotated[TurnOnBehavior, Alias("soft_on")] + #: The behavior when the bulb has been off from mains power. + hard: Annotated[TurnOnBehavior, Alias("hard_on")] + + +TPLINK_KELVIN = { + "LB130": ColorTempRange(2500, 9000), + "LB120": ColorTempRange(2700, 6500), + "LB230": ColorTempRange(2500, 9000), + "KB130": ColorTempRange(2500, 9000), + "KL130": ColorTempRange(2500, 9000), + "KL125": ColorTempRange(2500, 6500), + "KL135": ColorTempRange(2500, 9000), + r"KL120\(EU\)": ColorTempRange(2700, 6500), + r"KL120\(US\)": ColorTempRange(2700, 5000), + r"KL430": ColorTempRange(2500, 9000), +} + + +NON_COLOR_MODE_FLAGS = {"transition_period", "on_off"} + +_LOGGER = logging.getLogger(__name__) + + +class IotBulb(IotDevice): + r"""Representation of a TP-Link Smart Bulb. + + To initialize, you have to await :func:`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. + + Errors reported by the device are raised as + :class:`KasaException `, + and should be handled by the user of the library. + + Examples: + >>> import asyncio + >>> bulb = IotBulb("127.0.0.1") + >>> asyncio.run(bulb.update()) + >>> print(bulb.alias) + Bulb2 + + Bulbs, like any other supported devices, can be turned on and off: + + >>> asyncio.run(bulb.turn_off()) + >>> asyncio.run(bulb.turn_on()) + >>> asyncio.run(bulb.update()) + >>> print(bulb.is_on) + True + + You can use the ``is_``-prefixed properties to check for supported features: + + >>> bulb.is_dimmable + True + >>> bulb.is_color + True + >>> bulb.is_variable_color_temp + True + + All known bulbs support changing the brightness: + + >>> bulb.brightness + 30 + >>> asyncio.run(bulb.set_brightness(50)) + >>> asyncio.run(bulb.update()) + >>> bulb.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)) + >>> asyncio.run(bulb.update()) + >>> bulb.color_temp + 3000 + + Color bulbs can be adjusted by passing hue, saturation and value: + + >>> asyncio.run(bulb.set_hsv(180, 100, 80)) + >>> asyncio.run(bulb.update()) + >>> bulb.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: + + * :func:`turn_on` + * :func:`turn_off` + * :func:`set_hsv` + * :func:`set_color_temp` + * :func:`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)) + + Bulb configuration presets can be accessed using the :func:`presets` property: + + >>> [ 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}] + + To modify an existing preset, pass :class:`~kasa.interfaces.light.LightPreset` + instance to :func:`save_preset` method: + + >>> preset = bulb.presets[0] + >>> preset.brightness + 50 + >>> preset.brightness = 100 + >>> asyncio.run(bulb.save_preset(preset)) + >>> asyncio.run(bulb.update()) + >>> bulb.presets[0].brightness + 100 + + """ # noqa: E501 + + LIGHT_SERVICE = "smartlife.iot.smartbulb.lightingservice" + SET_LIGHT_METHOD = "transition_light_state" + emeter_type = "smartlife.iot.common.emeter" + + 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.Bulb + + async def _initialize_modules(self) -> None: + """Initialize modules not added in init.""" + await super()._initialize_modules() + self.add_module( + Module.IotSchedule, Schedule(self, "smartlife.iot.common.schedule") + ) + self.add_module(Module.IotUsage, Usage(self, "smartlife.iot.common.schedule")) + self.add_module( + Module.IotAntitheft, Antitheft(self, "smartlife.iot.common.anti_theft") + ) + self.add_module(Module.Time, Time(self, "smartlife.iot.common.timesetting")) + self.add_module(Module.Energy, Emeter(self, self.emeter_type)) + self.add_module(Module.IotCountdown, Countdown(self, "countdown")) + self.add_module(Module.IotCloud, Cloud(self, "smartlife.iot.common.cloud")) + self.add_module(Module.Light, Light(self, self.LIGHT_SERVICE)) + self.add_module(Module.LightPreset, LightPreset(self, self.LIGHT_SERVICE)) + + @property # type: ignore + @requires_update + def _is_color(self) -> bool: + """Whether the bulb supports color changes.""" + sys_info = self.sys_info + return bool(sys_info["is_color"]) + + @property # type: ignore + @requires_update + def _is_dimmable(self) -> bool: + """Whether the bulb supports brightness changes.""" + sys_info = self.sys_info + return bool(sys_info["is_dimmable"]) + + @property # type: ignore + @requires_update + def _is_variable_color_temp(self) -> bool: + """Whether the bulb supports color temperature changes.""" + sys_info = self.sys_info + return bool(sys_info["is_variable_color_temp"]) + + @property # type: ignore + @requires_update + 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") + + for model, temp_range in TPLINK_KELVIN.items(): + sys_info = self.sys_info + if re.match(model, sys_info["model"]): + return temp_range + + _LOGGER.warning("Unknown color temperature range, fallback to 2700-5000") + return ColorTempRange(2700, 5000) + + @property # type: ignore + @requires_update + def light_state(self) -> dict[str, str]: + """Query the light state.""" + light_state = self.sys_info["light_state"] + if light_state is None: + raise KasaException( + "The device has no light_state or you have not called update()" + ) + + # if the bulb is off, its state is stored under a different key + # as is_on property depends on on_off itself, we check it here manually + is_on = light_state["on_off"] + if not is_on: + off_state = {**light_state["dft_on_state"], "on_off": is_on} + return cast(dict, off_state) + + return light_state + + @property # type: ignore + @requires_update + def _has_effects(self) -> bool: + """Return True if the device supports effects.""" + return "lighting_effect_state" in self.sys_info + + async def get_light_details(self) -> dict[str, int]: + """Return light details. + + Example:: + + {'lamp_beam_angle': 290, 'min_voltage': 220, 'max_voltage': 240, + 'wattage': 5, 'incandescent_equivalent': 40, 'max_lumens': 450, + 'color_rendering_index': 80} + """ + return await self._query_helper(self.LIGHT_SERVICE, "get_light_details") + + async def get_turn_on_behavior(self) -> TurnOnBehaviors: + """Return the behavior for turning the bulb on.""" + return TurnOnBehaviors.from_dict( + await self._query_helper(self.LIGHT_SERVICE, "get_default_behavior") + ) + + async def set_turn_on_behavior(self, behavior: TurnOnBehaviors) -> dict: + """Set the behavior for turning the bulb on. + + If you do not want to manually construct the behavior object, + you should use :func:`get_turn_on_behavior` to get the current settings. + """ + return await self._query_helper( + self.LIGHT_SERVICE, "set_default_behavior", behavior.to_dict() + ) + + async def get_light_state(self) -> dict[str, dict]: + """Query the light state.""" + # TODO: add warning and refer to use light.state? + return await self._query_helper(self.LIGHT_SERVICE, "get_light_state") + + async def _set_light_state( + self, state: dict, *, transition: int | None = None + ) -> dict: + """Set the light state.""" + state = {**state} + if transition is not None: + state["transition_period"] = transition + + if "brightness" in state: + self._raise_for_invalid_brightness(state["brightness"]) + + # if no on/off is defined, turn on the light + if "on_off" not in state: + state["on_off"] = 1 + + # If we are turning on without any color mode flags, + # we do not want to set ignore_default to ensure + # we restore the previous state. + if state["on_off"] and NON_COLOR_MODE_FLAGS.issuperset(state): + state["ignore_default"] = 0 + else: + # This is necessary to allow turning on into a specific state + state["ignore_default"] = 1 + + light_state = await self._query_helper( + self.LIGHT_SERVICE, self.SET_LIGHT_METHOD, state + ) + return light_state + + @property # type: ignore + @requires_update + def _hsv(self) -> HSV: + """Return the current HSV state of the bulb. + + :return: hue, saturation and value (degrees, %, %) + """ + if not self._is_color: + raise KasaException("Bulb does not support color.") + + light_state = cast(dict, self.light_state) + + hue = light_state["hue"] + saturation = light_state["saturation"] + value = self._brightness + + # Simple HSV(hue, saturation, value) is less efficent than below + # due to the cpython implementation. + return tuple.__new__(HSV, (hue, saturation, value)) + + @requires_update + async def _set_hsv( + self, + hue: int, + saturation: int, + value: int | None = None, + *, + transition: int | None = None, + ) -> dict: + """Set new HSV. + + :param int hue: hue in degrees + :param int saturation: saturation in percentage [0,100] + :param int value: value in percentage [0, 100] + :param int transition: transition in milliseconds. + """ + if not self._is_color: + raise KasaException("Bulb does not support color.") + + if not isinstance(hue, int): + raise TypeError("Hue must be an integer.") + if not (0 <= hue <= 360): + raise ValueError(f"Invalid hue value: {hue} (valid range: 0-360)") + + if not isinstance(saturation, int): + raise TypeError("Saturation must be an integer.") + if not (0 <= saturation <= 100): + raise ValueError( + f"Invalid saturation value: {saturation} (valid range: 0-100%)" + ) + + light_state = { + "hue": hue, + "saturation": saturation, + "color_temp": 0, + } + + if value is not None: + self._raise_for_invalid_brightness(value) + light_state["brightness"] = value + + return await self._set_light_state(light_state, transition=transition) + + @property # type: ignore + @requires_update + def _color_temp(self) -> int: + """Return color temperature of the device in kelvin.""" + if not self._is_variable_color_temp: + raise KasaException("Bulb does not support colortemp.") + + light_state = self.light_state + return int(light_state["color_temp"]) + + @requires_update + async def _set_color_temp( + self, temp: int, *, brightness: int | None = None, transition: int | None = None + ) -> dict: + """Set the color temperature of the device in kelvin. + + :param int temp: The new color temperature, in Kelvin + :param int transition: transition in milliseconds. + """ + if not self._is_variable_color_temp: + raise KasaException("Bulb does not support colortemp.") + + valid_temperature_range = self._valid_temperature_range + if temp < valid_temperature_range[0] or temp > valid_temperature_range[1]: + raise ValueError( + "Temperature should be between {} and {}, was {}".format( + *valid_temperature_range, temp + ) + ) + + light_state = {"color_temp": temp} + if brightness is not None: + light_state["brightness"] = brightness + + return await self._set_light_state(light_state, transition=transition) + + def _raise_for_invalid_brightness(self, value: int) -> None: + if not isinstance(value, int): + raise TypeError("Brightness must be an integer") + if not (0 <= value <= 100): + raise ValueError(f"Invalid brightness value: {value} (valid range: 0-100%)") + + @property # type: ignore + @requires_update + def _brightness(self) -> int: + """Return the current brightness in percentage.""" + if not self._is_dimmable: # pragma: no cover + raise KasaException("Bulb is not dimmable.") + + # If the device supports effects and one is active, we get the brightness + # from the effect. This is not required when setting the brightness as + # the device handles it via set_light_state + if ( + light_effect := self.modules.get(Module.IotLightEffect) + ) is not None and light_effect.effect != light_effect.LIGHT_EFFECTS_OFF: + return light_effect.brightness + light_state = self.light_state + return int(light_state["brightness"]) + + @requires_update + async def _set_brightness( + self, brightness: int, *, transition: int | None = None + ) -> dict: + """Set the brightness in percentage. + + :param int brightness: brightness in percent + :param int transition: transition in milliseconds. + """ + if not self._is_dimmable: # pragma: no cover + raise KasaException("Bulb is not dimmable.") + + self._raise_for_invalid_brightness(brightness) + + light_state = {"brightness": brightness} + return await self._set_light_state(light_state, transition=transition) + + @property # type: ignore + @requires_update + def is_on(self) -> bool: + """Return whether the device is on.""" + light_state = self.light_state + return bool(light_state["on_off"]) + + async def turn_off(self, *, transition: int | None = None, **kwargs) -> dict: + """Turn the bulb off. + + :param int transition: transition in milliseconds. + """ + return await self._set_light_state({"on_off": 0}, transition=transition) + + async def turn_on(self, *, transition: int | None = None, **kwargs) -> dict: + """Turn the bulb on. + + :param int transition: transition in milliseconds. + """ + return await self._set_light_state({"on_off": 1}, transition=transition) + + @property # type: ignore + @requires_update + def has_emeter(self) -> bool: + """Return that the bulb has an emeter.""" + return True + + async def set_alias(self, alias: str) -> dict: + """Set the device name (alias). + + Overridden to use a different module name. + """ + return await self._query_helper( + "smartlife.iot.common.system", "set_dev_alias", {"alias": alias} + ) + + @property + def max_device_response_size(self) -> int: + """Returns the maximum response size the device can safely construct.""" + return 4096 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 new file mode 100755 index 000000000..90bac7054 --- /dev/null +++ b/kasa/iot/iotdevice.py @@ -0,0 +1,785 @@ +"""Python library supporting TP-Link Smart Home devices. + +The communication protocol was reverse engineered by Lubomir Stroetmann and +Tobias Esser in 'Reverse Engineering the TP-Link HS110': +https://www.softscheck.com/en/reverse-engineering-tp-link-hs110/ + +This library reuses codes and concepts of the TP-Link WiFi SmartPlug Client +at https://github.com/softScheck/tplink-smartplug, developed by Lubomir +Stroetmann which is licensed under the Apache License, Version 2.0. + +You may obtain a copy of the license at +http://www.apache.org/licenses/LICENSE-2.0 +""" + +from __future__ import annotations + +import functools +import inspect +import logging +from collections.abc import Callable, Mapping, Sequence +from datetime import datetime, timedelta, tzinfo +from typing import TYPE_CHECKING, Any, cast +from warnings import warn + +from ..device import Device, DeviceInfo, WifiNetwork +from ..device_type import DeviceType +from ..deviceconfig import DeviceConfig +from ..exceptions import KasaException +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, HomeKit + +_LOGGER = logging.getLogger(__name__) + + +def requires_update(f: Callable) -> Any: + """Indicate that `update` should be called before accessing this method.""" # noqa: D202 + if inspect.iscoroutinefunction(f): + + @functools.wraps(f) + async def wrapped(*args: Any, **kwargs: Any) -> Any: + self = args[0] + 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") + return await f(*args, **kwargs) + + else: + + @functools.wraps(f) + def wrapped(*args: Any, **kwargs: Any) -> Any: + self = args[0] + 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") + return f(*args, **kwargs) + + f.requires_update = True # type: ignore[attr-defined] + return wrapped + + +@functools.lru_cache +def _parse_features(features: str) -> set[str]: + """Parse features string.""" + 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. + + You don't usually want to initialize this class manually, + but either use :class:`Discover` class, or use one of the subclasses: + + * :class:`IotPlug` + * :class:`IotBulb` + * :class:`IotStrip` + * :class:`IotDimmer` + * :class:`IotLightStrip` + + To initialize, you have to await :func:`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 update() separately. + + Errors reported by the device are raised as + :class:`KasaException `, + and should be handled by the user of the library. + + Examples: + >>> import asyncio + >>> dev = IotDevice("127.0.0.1") + >>> asyncio.run(dev.update()) + + All devices provide several informational properties: + + >>> dev.alias + Bedroom Lamp Plug + >>> dev.model + HS110 + >>> dev.rssi + -71 + >>> dev.mac + 50:C7:BF:00:00:00 + + Some information can also be changed programmatically: + + >>> asyncio.run(dev.set_alias("new alias")) + >>> asyncio.run(dev.set_mac("01:23:45:67:89:ab")) + >>> asyncio.run(dev.update()) + >>> dev.alias + new alias + >>> dev.mac + 01:23:45:67:89:ab + + When initialized using discovery or using a subclass, + you can check the type of the device: + + >>> dev.is_bulb + False + >>> dev.is_strip + False + >>> dev.is_plug + True + + You can also get the hardware and software as a dict, + or access the full device response: + + >>> dev.hw_info + {'sw_ver': '1.2.5 Build 171213 Rel.101523', + 'hw_ver': '1.0', + 'mac': '01:23:45:67:89:ab', + 'type': 'IOT.SMARTPLUGSWITCH', + 'hwId': '00000000000000000000000000000000', + 'fwId': '00000000000000000000000000000000', + 'oemId': '00000000000000000000000000000000', + 'dev_name': 'Wi-Fi Smart Plug With Energy Monitoring'} + >>> dev.sys_info + + All devices can be turned on and off: + + >>> asyncio.run(dev.turn_off()) + >>> asyncio.run(dev.turn_on()) + >>> asyncio.run(dev.update()) + >>> dev.is_on + True + + Some devices provide energy consumption meter, + and regular update will already fetch some information: + + >>> dev.has_emeter + True + >>> dev.emeter_realtime + + >>> dev.emeter_today + >>> dev.emeter_this_month + + You can also query the historical data (note that these needs to be awaited), + keyed with month/day: + + >>> asyncio.run(dev.get_emeter_monthly(year=2016)) + {11: 1.089, 12: 1.582} + >>> asyncio.run(dev.get_emeter_daily(year=2016, month=11)) + {24: 0.026, 25: 0.109} + + """ + + emeter_type = "emeter" + + def __init__( + self, + host: str, + *, + config: DeviceConfig | None = None, + protocol: BaseProtocol | None = None, + ) -> None: + """Create a new IotDevice instance.""" + super().__init__(host=host, config=config, protocol=protocol) + + self._sys_info: Any = None # TODO: this is here to avoid changing tests + self._supported_modules: dict[str | ModuleName[Module], IotModule] | None = None + self._legacy_features: set[str] = set() + self._children: Mapping[str, IotDevice] = {} + self._modules: dict[str | ModuleName[Module], IotModule] = {} + self._on_since: datetime | None = None + + @property + def children(self) -> Sequence[IotDevice]: + """Return list of children.""" + return list(self._children.values()) + + @property + @requires_update + def modules(self) -> ModuleMapping[IotModule]: + """Return the device modules.""" + if TYPE_CHECKING: + return cast(ModuleMapping[IotModule], self._supported_modules) + return self._supported_modules + + def add_module(self, name: str | ModuleName[Module], module: IotModule) -> None: + """Register a module.""" + if name in self._modules: + _LOGGER.debug("Module %s already registered, ignoring...", name) + return + + _LOGGER.debug("Adding module %s", module) + self._modules[name] = module + + def _create_request( + self, + target: str, + cmd: str, + arg: dict | None = None, + child_ids: list | None = None, + ) -> dict: + if arg is None: + arg = {} + request: dict[str, Any] = {target: {cmd: arg}} + if child_ids is not None: + request = {"context": {"child_ids": child_ids}, target: {cmd: arg}} + + return request + + def _verify_emeter(self) -> None: + """Raise an exception if there is no emeter.""" + if not self.has_emeter: + raise KasaException("Device has no emeter") + if self.emeter_type not in self._last_update: + raise KasaException("update() required prior accessing emeter") + + async def _query_helper( + self, + target: str, + cmd: str, + arg: dict | None = None, + child_ids: list | None = None, + ) -> dict: + """Query device, return results or raise an exception. + + :param target: Target system {system, time, emeter, ..} + :param cmd: Command to execute + :param arg: payload dict to be send to the device + :param child_ids: ids of child devices + :return: Unwrapped result for the call. + """ + request = self._create_request(target, cmd, arg, child_ids) + + try: + response = await self._raw_query(request=request) + except Exception as ex: + raise KasaException(f"Communication error on {target}:{cmd}") from ex + + if target not in response: + raise KasaException(f"No required {target} in response: {response}") + + result = response[target] + if "err_code" in result and result["err_code"] != 0: + raise KasaException(f"Error on {target}.{cmd}: {result}") + + if cmd not in result: + raise KasaException(f"No command in response: {response}") + result = result[cmd] + if "err_code" in result and result["err_code"] != 0: + raise KasaException(f"Error on {target} {cmd}: {result}") + + if "err_code" in result: + del result["err_code"] + + return result + + @property # type: ignore + @requires_update + def features(self) -> dict[str, Feature]: + """Return a set of features that the device supports.""" + return self._features + + @property # type: ignore + @requires_update + def has_emeter(self) -> bool: + """Return True if device has an energy meter.""" + return "ENE" in self._legacy_features + + async def get_sys_info(self) -> dict[str, Any]: + """Retrieve system information.""" + return await self._query_helper("system", "get_sysinfo") + + async def update(self, update_children: bool = True) -> None: + """Query the device to update the data. + + Needed for properties that are decorated with `requires_update`. + """ + req = {} + req.update(self._create_request("system", "get_sysinfo")) + + # 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 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(_extract_sys_info(response)) + + if not self._modules: + await self._initialize_modules() + + await self._modular_update(req) + + self._set_sys_info(_extract_sys_info(self._last_update)) + for module in self._modules.values(): + await module._post_update_hook() + + if not self._features: + await self._initialize_features() + + 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" + ) + self.add_module(Module.Energy, Emeter(self, self.emeter_type)) + + async def _initialize_features(self) -> None: + """Initialize common features.""" + self._add_feature( + Feature( + self, + id="state", + name="State", + attribute_getter="is_on", + attribute_setter="set_state", + type=Feature.Type.Switch, + category=Feature.Category.Primary, + ) + ) + self._add_feature( + Feature( + device=self, + id="rssi", + name="RSSI", + attribute_getter="rssi", + icon="mdi:signal", + unit_getter=lambda: "dBm", + category=Feature.Category.Debug, + type=Feature.Type.Sensor, + ) + ) + # iot strips calculate on_since from the children + if "on_time" in self._sys_info or self.device_type == Device.Type.Strip: + self._add_feature( + Feature( + device=self, + id="on_since", + name="On since", + attribute_getter="on_since", + icon="mdi:clock", + category=Feature.Category.Info, + type=Feature.Type.Sensor, + ) + ) + + self._add_feature( + Feature( + device=self, + id="reboot", + name="Reboot", + attribute_setter="reboot", + icon="mdi:restart", + category=Feature.Category.Debug, + type=Feature.Type.Action, + ) + ) + + for module in self.modules.values(): + module._initialize_features() + for module_feat in module._module_features.values(): + self._add_feature(module_feat) + + async def _modular_update(self, req: dict) -> None: + """Execute an update query.""" + request_list = [] + est_response_size = 1024 if "system" in req else 0 + for module in self._modules.values(): + if not module.is_supported: + _LOGGER.debug("Module %s not supported, skipping", module) + continue + + est_response_size += module.estimated_query_response_size + if est_response_size > self.max_device_response_size: + request_list.append(req) + req = {} + est_response_size = module.estimated_query_response_size + + q = module.query() + _LOGGER.debug("Adding query for %s: %s", module, q) + req = merge(req, q) + request_list.append(req) + + responses = [ + await self.protocol.query(request) for request in request_list if request + ] + + # Preserve the last update and merge + # responses on top of it so we remember + # which modules are not supported, otherwise + # every other update will query for them + update: dict = self._last_update.copy() if self._last_update else {} + for response in responses: + for k, v in response.items(): + # The same module could have results in different responses + # i.e. smartlife.iot.common.schedule for Usage and + # Schedule, so need to call update(**v) here. If a module is + # not supported the response + # {'err_code': -1, 'err_msg': 'module not support'} + # become top level key/values of the response so check for dict + if isinstance(v, dict): + update.setdefault(k, {}).update(**v) + self._last_update = update + + # IOT modules are added as default but could be unsupported post first update + if self._supported_modules is None: + supported = {} + for module_name, module in self._modules.items(): + if module.is_supported: + supported[module_name] = module + + self._supported_modules = supported + + def update_from_discover_info(self, info: dict[str, Any]) -> None: + """Update state from info from the discover call.""" + self._discovery_info = info + if "system" in info and (sys_info := info["system"].get("get_sysinfo")): + self._last_update = info + self._set_sys_info(sys_info) + else: + # This allows setting of some info properties directly + # from partial discovery info that will then be found + # by the requires_update decorator + 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.""" + self._sys_info = sys_info + if features := sys_info.get("feature"): + self._legacy_features = _parse_features(features) + + @property # type: ignore + @requires_update + def sys_info(self) -> dict[str, Any]: + """ + Return system information. + + Do not call this function from within the SmartDevice + class itself as @requires_update will be affected for other properties. + """ + return self._sys_info # type: ignore + + @property + @requires_update + 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: + """Return device name (alias).""" + sys_info = self._sys_info + return sys_info.get("alias") if sys_info else None + + async def set_alias(self, alias: str) -> dict: + """Set the device name (alias).""" + return await self._query_helper("system", "set_dev_alias", {"alias": alias}) + + @property + @requires_update + def time(self) -> datetime: + """Return current time from the device.""" + return self.modules[Module.Time].time + + @property + @requires_update + def timezone(self) -> tzinfo: + """Return the current timezone.""" + return self.modules[Module.Time].timezone + + async def get_time(self) -> datetime: + """Return current time from the device, if available.""" + msg = "Use `time` property instead, this call will be removed in the future." + warn(msg, DeprecationWarning, stacklevel=2) + return self.time + + async def get_timezone(self) -> tzinfo: + """Return timezone information.""" + msg = ( + "Use `timezone` property instead, this call will be removed in the future." + ) + warn(msg, DeprecationWarning, stacklevel=2) + return self.timezone + + @property # type: ignore + @requires_update + def hw_info(self) -> dict: + """Return hardware information. + + This returns just a selection of sysinfo keys that are related to hardware. + """ + keys = [ + "sw_ver", + "hw_ver", + "mac", + "mic_mac", + "type", + "mic_type", + "hwId", + "fwId", + "oemId", + "dev_name", + ] + sys_info = self._sys_info + return {key: sys_info[key] for key in keys if key in sys_info} + + @property # type: ignore + @requires_update + def location(self) -> dict: + """Return geographical location.""" + sys_info = self._sys_info + loc = {"latitude": None, "longitude": None} + + if "latitude" in sys_info and "longitude" in sys_info: + loc["latitude"] = sys_info["latitude"] + loc["longitude"] = sys_info["longitude"] + elif "latitude_i" in sys_info and "longitude_i" in sys_info: + loc["latitude"] = sys_info["latitude_i"] / 10000 + loc["longitude"] = sys_info["longitude_i"] / 10000 + else: + _LOGGER.debug("Unsupported device location.") + + return loc + + @property # type: ignore + @requires_update + def rssi(self) -> int | None: + """Return WiFi signal strength (rssi).""" + rssi = self._sys_info.get("rssi") + return None if rssi is None else int(rssi) + + @property # type: ignore + @requires_update + def mac(self) -> str: + """Return mac address. + + :return: mac address in hexadecimal with colons, e.g. 01:23:45:67:89:ab + """ + sys_info = self._sys_info + mac = sys_info.get("mac", sys_info.get("mic_mac")) + if not mac: + raise KasaException( + "Unknown mac, please submit a bug report with sys_info output." + ) + mac = mac.replace("-", ":") + # Format a mac that has no colons (usually from mic_mac field) + if ":" not in mac: + mac = ":".join(format(s, "02x") for s in bytes.fromhex(mac)) + + return mac + + async def set_mac(self, mac: str) -> dict: + """Set the mac address. + + :param str mac: mac in hexadecimal with colons, e.g. 01:23:45:67:89:ab + """ + return await self._query_helper("system", "set_mac_addr", {"mac": mac}) + + async def reboot(self, delay: int = 1) -> None: + """Reboot the device. + + Note that giving a delay of zero causes this to block, + as the device reboots immediately without responding to the call. + """ + await self._query_helper("system", "reboot", {"delay": delay}) + + async def factory_reset(self) -> None: + """Reset device back to factory settings. + + Note, this does not downgrade the firmware. + """ + await self._query_helper("system", "reset") + + async def turn_off(self, **kwargs) -> dict: + """Turn off the device.""" + raise NotImplementedError("Device subclass needs to implement this.") + + async def turn_on(self, **kwargs) -> dict: + """Turn device on.""" + raise NotImplementedError("Device subclass needs to implement this.") + + @property # type: ignore + @requires_update + def is_on(self) -> bool: + """Return True if the device is on.""" + raise NotImplementedError("Device subclass needs to implement this.") + + async def set_state(self, on: bool) -> dict: + """Set the device state.""" + if on: + return await self.turn_on() + else: + return await self.turn_off() + + @property # type: ignore + @requires_update + def on_since(self) -> datetime | None: + """Return the time that the device was turned on or None if turned off. + + This returns a cached value if the device reported value difference is under + five seconds to avoid device-caused jitter. + """ + if self.is_off or "on_time" not in self._sys_info: + self._on_since = None + return None + + on_time = self._sys_info["on_time"] + + on_since = self.time - timedelta(seconds=on_time) + if not self._on_since or timedelta( + seconds=0 + ) < on_since - self._on_since > timedelta(seconds=5): + self._on_since = on_since + return self._on_since + + @property # type: ignore + @requires_update + def device_id(self) -> str: + """Return unique ID for the device. + + If not overridden, this is the MAC address of the device. + Individual sockets on strips will override this. + """ + return self.mac + + async def wifi_scan(self) -> list[WifiNetwork]: # noqa: D202 + """Scan for available wifi networks.""" + + async def _scan(target: str) -> dict: + return await self._query_helper(target, "get_scaninfo", {"refresh": 1}) + + try: + info = await _scan("netif") + except KasaException as ex: + _LOGGER.debug( + "Unable to scan using 'netif', retrying with 'softaponboarding': %s", ex + ) + info = await _scan("smartlife.iot.common.softaponboarding") + + if "ap_list" not in info: + raise KasaException(f"Invalid response for wifi scan: {info}") + + return [WifiNetwork(**x) for x in info["ap_list"]] + + async def wifi_join(self, ssid: str, password: str, keytype: str = "3") -> dict: # noqa: D202 + """Join the given wifi network. + + If joining the network fails, the device will return to AP mode after a while. + """ + + 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) + except KasaException as ex: + _LOGGER.debug( + "Unable to join using 'netif', retrying with 'softaponboarding': %s", ex + ) + return await _join("smartlife.iot.common.softaponboarding", payload) + + @property + def max_device_response_size(self) -> int: + """Returns the maximum response size the device can safely construct.""" + return 16 * 1024 + + @property + def internal_state(self) -> Any: + """Return the internal state of the instance. + + The returned object contains the raw results from the last update call. + This should only be used for debugging purposes. + """ + return self._last_update or self._discovery_info + + @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] = _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!") + + if "dev_name" in sysinfo and "Dimmer" in sysinfo["dev_name"]: + return DeviceType.Dimmer + + if "smartplug" in type_.lower(): + if "children" in sysinfo: + return DeviceType.Strip + if (dev_name := sysinfo.get("dev_name")) and "light" in dev_name.lower(): + return DeviceType.WallSwitch + return DeviceType.Plug + + if "smartbulb" in type_.lower(): + if "length" in sysinfo: # strips have length + 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: + """Get model information for a device.""" + sys_info = _extract_sys_info(info) + + # Get model and region info + region = None + device_model = sys_info["model"] + long_name, _, region = device_model.partition("(") + if region: # All iot devices have region but just in case + region = region.replace(")", "") + + # Get other info + device_family = sys_info.get("type", sys_info.get("mic_type")) + device_type = IotDevice._get_device_type_from_sys_info(info) + fw_version_full = sys_info["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 + auth = bool(discovery_info and ("mgt_encrypt_schm" in discovery_info)) + + return DeviceInfo( + short_name=long_name, + long_name=long_name, + brand="kasa", + device_family=device_family, + device_type=device_type, + hardware_version=sys_info["hw_ver"], + firmware_version=firmware_version, + firmware_build=firmware_build, + requires_auth=auth, + region=region, + ) diff --git a/kasa/iot/iotdimmer.py b/kasa/iot/iotdimmer.py new file mode 100644 index 000000000..6b22d640b --- /dev/null +++ b/kasa/iot/iotdimmer.py @@ -0,0 +1,240 @@ +"""Module for dimmers (currently only HS220).""" + +from __future__ import annotations + +from enum import Enum +from typing import Any + +from ..device_type import DeviceType +from ..deviceconfig import DeviceConfig +from ..module import Module +from ..protocols import BaseProtocol +from .iotdevice import KasaException, requires_update +from .iotplug import IotPlug +from .modules import AmbientLight, Dimmer, Light, Motion + + +class ButtonAction(Enum): + """Button action.""" + + NoAction = "none" + Instant = "instant_on_off" + Gentle = "gentle_on_off" + Preset = "customize_preset" + + +class ActionType(Enum): + """Button action.""" + + DoubleClick = "double_click_action" + LongPress = "long_press_action" + + +class FadeType(Enum): + """Fade on/off setting.""" + + FadeOn = "fade_on" + FadeOff = "fade_off" + + +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. + + To initialize, you have to await :func:`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. + + Errors reported by the device are raised as :class:`KasaException`\s, + and should be handled by the user of the library. + + Examples: + >>> import asyncio + >>> dimmer = IotDimmer("192.168.1.105") + >>> asyncio.run(dimmer.turn_on()) + >>> dimmer.brightness + 25 + + >>> asyncio.run(dimmer.set_brightness(50)) + >>> asyncio.run(dimmer.update()) + >>> dimmer.brightness + 50 + + Refer to :class:`SmartPlug` for the full API. + """ + + DIMMER_SERVICE = "smartlife.iot.dimmer" + + 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.Dimmer + + async def _initialize_modules(self) -> None: + """Initialize modules.""" + await super()._initialize_modules() + # TODO: need to be verified if it's okay to call these on HS220 w/o these + # 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 + @requires_update + def _brightness(self) -> int: + """Return current brightness on dimmers. + + Will return a range between 0 - 100. + """ + if not self._is_dimmable: + raise KasaException("Device is not dimmable.") + + sys_info = self.sys_info + return int(sys_info["brightness"]) + + @requires_update + async def _set_brightness( + self, brightness: int, *, transition: int | None = None + ) -> dict: + """Set the new dimmer brightness level in percentage. + + :param int transition: transition duration in milliseconds. + Using a transition will cause the dimmer to turn on. + """ + if not self._is_dimmable: + raise KasaException("Device is not dimmable.") + + if not isinstance(brightness, int): + raise ValueError("Brightness must be integer, not of %s.", type(brightness)) + + if not 0 <= brightness <= 100: + raise ValueError( + f"Invalid brightness value: {brightness} (valid range: 0-100%)" + ) + + # Dimmers do not support a brightness of 0, but bulbs do. + # Coerce 0 to 1 to maintain the same interface between dimmers and bulbs. + if brightness == 0: + brightness = 1 + + if transition is not None: + return await self.set_dimmer_transition(brightness, transition) + + return await self._query_helper( + self.DIMMER_SERVICE, "set_brightness", {"brightness": brightness} + ) + + async def turn_off(self, *, transition: int | None = None, **kwargs) -> dict: + """Turn the bulb off. + + :param int transition: transition duration in milliseconds. + """ + if transition is not None: + return await self.set_dimmer_transition(brightness=0, transition=transition) + + return await super().turn_off() + + @requires_update + async def turn_on(self, *, transition: int | None = None, **kwargs) -> dict: + """Turn the bulb on. + + :param int transition: transition duration in milliseconds. + """ + if transition is not None: + return await self.set_dimmer_transition( + brightness=self._brightness, transition=transition + ) + + return await super().turn_on() + + async def set_dimmer_transition(self, brightness: int, transition: int) -> dict: + """Turn the bulb on to brightness percentage over transition milliseconds. + + A brightness value of 0 will turn off the dimmer. + """ + if not isinstance(brightness, int): + raise TypeError(f"Brightness must be an integer, not {type(brightness)}.") + + if not 0 <= brightness <= 100: + raise ValueError( + f"Invalid brightness value: {brightness} (valid range: 0-100%)" + ) + + # If zero set to 1 millisecond + if transition == 0: + transition = 1 + if not isinstance(transition, int): + raise TypeError(f"Transition must be integer, not of {type(transition)}.") + if transition <= 0: + raise ValueError(f"Transition value {transition} is not valid.") + + return await self._query_helper( + self.DIMMER_SERVICE, + "set_dimmer_transition", + {"brightness": brightness, "duration": transition}, + ) + + @requires_update + async def get_behaviors(self) -> dict: + """Return button behavior settings.""" + behaviors = await self._query_helper( + self.DIMMER_SERVICE, "get_default_behavior", {} + ) + return behaviors + + @requires_update + async def set_button_action( + self, action_type: ActionType, action: ButtonAction, index: int | None = None + ) -> dict: + """Set action to perform on button click/hold. + + :param action_type ActionType: whether to control double click or hold action. + :param action ButtonAction: what should the button do + (nothing, instant, gentle, change preset) + :param index int: in case of preset change, the preset to select + """ + action_type_setter = f"set_{action_type}" + + payload: dict[str, Any] = {"mode": str(action)} + if index is not None: + payload["index"] = index + + return await self._query_helper( + self.DIMMER_SERVICE, action_type_setter, payload + ) + + @requires_update + async def set_fade_time(self, fade_type: FadeType, time: int) -> dict: + """Set time for fade in / fade out.""" + fade_type_setter = f"set_{fade_type}_time" + payload = {"fadeTime": time} + + return await self._query_helper(self.DIMMER_SERVICE, fade_type_setter, payload) + + @property # type: ignore + @requires_update + def _is_dimmable(self) -> bool: + """Whether the switch supports brightness changes.""" + sys_info = self.sys_info + return "brightness" in sys_info + + @property + def _is_variable_color_temp(self) -> bool: + """Whether the device supports variable color temp.""" + return False + + @property + def _is_color(self) -> bool: + """Whether the device supports color.""" + return False diff --git a/kasa/iot/iotlightstrip.py b/kasa/iot/iotlightstrip.py new file mode 100644 index 000000000..f4107b3c1 --- /dev/null +++ b/kasa/iot/iotlightstrip.py @@ -0,0 +1,72 @@ +"""Module for light strips (KL430).""" + +from __future__ import annotations + +from ..device_type import DeviceType +from ..deviceconfig import DeviceConfig +from ..module import Module +from ..protocols import BaseProtocol +from .iotbulb import IotBulb +from .iotdevice import requires_update +from .modules.lighteffect import LightEffect + + +class IotLightStrip(IotBulb): + """Representation of a TP-Link Smart light strip. + + 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. + + Examples: + >>> import asyncio + >>> strip = IotLightStrip("127.0.0.1") + >>> asyncio.run(strip.update()) + >>> print(strip.alias) + Bedroom Lightstrip + + Getting the length of the strip: + + >>> strip.length + 16 + + Currently active effect: + + >>> strip.effect + {'brightness': 100, 'custom': 0, 'enable': 0, + 'id': 'bCTItKETDFfrKANolgldxfgOakaarARs', 'name': 'Flicker'} + + .. note:: + The device supports some features that are not currently implemented, + feel free to find out how to control them and create a PR! + + + See :class:`SmartBulb` for more examples. + """ + + LIGHT_SERVICE = "smartlife.iot.lightStrip" + SET_LIGHT_METHOD = "set_light_state" + + 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.LightStrip + + async def _initialize_modules(self) -> None: + """Initialize modules not added in init.""" + await super()._initialize_modules() + self.add_module( + Module.LightEffect, + LightEffect(self, "smartlife.iot.lighting_effect"), + ) + + @property # type: ignore + @requires_update + def length(self) -> int: + """Return length of the strip.""" + return self.sys_info["length"] diff --git a/kasa/iot/iotmodule.py b/kasa/iot/iotmodule.py new file mode 100644 index 000000000..115e9e823 --- /dev/null +++ b/kasa/iot/iotmodule.py @@ -0,0 +1,76 @@ +"""Base class for IOT module implementations.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from ..exceptions import KasaException +from ..module import Module + +_LOGGER = logging.getLogger(__name__) + +if TYPE_CHECKING: + from .iotdevice import IotDevice + + +def _merge_dict(dest: dict, source: dict) -> dict: + """Update dict recursively.""" + for k, v in source.items(): + if k in dest and type(v) is dict: # noqa: E721 - only accepts `dict` type + _merge_dict(dest[k], v) + else: + dest[k] = v + return dest + + +merge = _merge_dict + + +class IotModule(Module): + """Base class implemention for all IOT modules.""" + + _device: IotDevice + + async def call(self, method: str, params: dict | None = None) -> dict: + """Call the given method with the given parameters.""" + return await self._device._query_helper(self._module, method, params) + + def query_for_command(self, query: str, params: dict | None = None) -> dict: + """Create a request object for the given parameters.""" + return self._device._create_request(self._module, query, params) + + @property + def estimated_query_response_size(self) -> int: + """Estimated maximum size of query response. + + The inheriting modules implement this to estimate how large a query response + will be so that queries can be split should an estimated response be too large + """ + return 256 # Estimate for modules that don't specify + + @property + def data(self) -> dict[str, Any]: + """Return the module specific raw data from the last update.""" + dev = self._device + q = self.query() + + if not q: + return dev.sys_info + + if self._module not in dev._last_update: + raise KasaException( + f"You need to call update() prior accessing module data" + f" for '{self._module}'" + ) + + return dev._last_update[self._module] + + @property + def is_supported(self) -> bool: + """Return whether the module is supported by the device.""" + if self._module not in self._device._last_update: + _LOGGER.debug("Initial update, so consider supported: %s", self._module) + return True + + return "err_code" not in self.data diff --git a/kasa/iot/iotplug.py b/kasa/iot/iotplug.py new file mode 100644 index 000000000..288d53763 --- /dev/null +++ b/kasa/iot/iotplug.py @@ -0,0 +1,104 @@ +"""Module for smart plugs (HS100, HS110, ..).""" + +from __future__ import annotations + +import logging +from typing import Any + +from ..device_type import DeviceType +from ..deviceconfig import DeviceConfig +from ..module import Module +from ..protocols import BaseProtocol +from .iotdevice import IotDevice, requires_update +from .modules import AmbientLight, Antitheft, Cloud, Led, Motion, Schedule, Time, Usage + +_LOGGER = logging.getLogger(__name__) + + +class IotPlug(IotDevice): + r"""Representation of a TP-Link Smart Plug. + + To initialize, you have to await :func:`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. + + Errors reported by the device are raised as :class:`KasaException`\s, + and should be handled by the user of the library. + + Examples: + >>> import asyncio + >>> plug = IotPlug("127.0.0.1") + >>> asyncio.run(plug.update()) + >>> plug.alias + Bedroom Lamp Plug + + Setting the LED state: + + >>> asyncio.run(plug.set_led(True)) + >>> asyncio.run(plug.update()) + >>> plug.led + True + + For more examples, see the :class:`Device` class. + """ + + 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.Plug + + async def _initialize_modules(self) -> None: + """Initialize modules.""" + await super()._initialize_modules() + self.add_module(Module.IotSchedule, Schedule(self, "schedule")) + self.add_module(Module.IotUsage, Usage(self, "schedule")) + self.add_module(Module.IotAntitheft, Antitheft(self, "anti_theft")) + self.add_module(Module.Time, Time(self, "time")) + self.add_module(Module.IotCloud, Cloud(self, "cnCloud")) + self.add_module(Module.Led, Led(self, "system")) + + @property # type: ignore + @requires_update + def is_on(self) -> bool: + """Return whether device is on.""" + sys_info = self.sys_info + return bool(sys_info["relay_state"]) + + async def turn_on(self, **kwargs: Any) -> dict: + """Turn the switch on.""" + return await self._query_helper("system", "set_relay_state", {"state": 1}) + + async def turn_off(self, **kwargs: Any) -> dict: + """Turn the switch off.""" + return await self._query_helper("system", "set_relay_state", {"state": 0}) + + +class IotWallSwitch(IotPlug): + """Representation of a TP-Link Smart Wall Switch.""" + + 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.WallSwitch + + async def _initialize_modules(self) -> None: + """Initialize modules.""" + await super()._initialize_modules() + if (dev_name := self.sys_info["dev_name"]) and "PIR" in dev_name: + self.add_module(Module.IotMotion, Motion(self, "smartlife.iot.PIR")) + self.add_module( + Module.IotAmbientLight, AmbientLight(self, "smartlife.iot.LAS") + ) diff --git a/kasa/iot/iotstrip.py b/kasa/iot/iotstrip.py new file mode 100755 index 000000000..7984ffb7c --- /dev/null +++ b/kasa/iot/iotstrip.py @@ -0,0 +1,519 @@ +"""Module for multi-socket devices (HS300, HS107, KP303, ..).""" + +from __future__ import annotations + +import logging +from collections import defaultdict +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 +from ..exceptions import KasaException +from ..feature import Feature +from ..interfaces import Energy +from ..module import Module +from ..protocols import BaseProtocol +from .iotdevice import ( + IotDevice, + requires_update, +) +from .iotmodule import IotModule +from .iotplug import IotPlug +from .modules import ( + Antitheft, + Cloud, + Countdown, + Emeter, + HomeKit, + Led, + Schedule, + Time, + Usage, +) + +_LOGGER = logging.getLogger(__name__) + + +def merge_sums(dicts: list[dict]) -> dict: + """Merge the sum of dicts.""" + total_dict: defaultdict[int, float] = defaultdict(lambda: 0.0) + for sum_dict in dicts: + for day, value in sum_dict.items(): + total_dict[day] += value + return total_dict + + +class IotStrip(IotDevice): + r"""Representation of a TP-Link Smart Power Strip. + + 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. + + To initialize, you have to await :func:`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. + + Errors reported by the device are raised as :class:`KasaException`\s, + and should be handled by the user of the library. + + Examples: + >>> import asyncio + >>> strip = IotStrip("127.0.0.1") + >>> asyncio.run(strip.update()) + >>> strip.alias + Bedroom Power Strip + + All methods act on the whole strip: + + >>> for plug in strip.children: + >>> print(f"{plug.alias}: {plug.is_on}") + Plug 1: True + Plug 2: False + Plug 3: False + >>> strip.is_on + True + >>> asyncio.run(strip.turn_off()) + >>> asyncio.run(strip.update()) + + Accessing individual plugs can be done using the `children` property: + + >>> len(strip.children) + 3 + >>> for plug in strip.children: + >>> print(f"{plug.alias}: {plug.is_on}") + Plug 1: False + Plug 2: False + Plug 3: False + >>> asyncio.run(strip.children[1].turn_on()) + >>> asyncio.run(strip.update()) + >>> strip.is_on + True + + For more examples, see the :class:`Device` class. + """ + + def __init__( + self, + host: str, + *, + config: DeviceConfig | None = None, + protocol: BaseProtocol | None = None, + ) -> None: + super().__init__(host=host, config=config, protocol=protocol) + self.emeter_type = "emeter" + self._device_type = DeviceType.Strip + + async def _initialize_modules(self) -> None: + """Initialize modules.""" + # Strip has different modules to plug so do not call super + self.add_module(Module.IotAntitheft, Antitheft(self, "anti_theft")) + self.add_module(Module.IotSchedule, Schedule(self, "schedule")) + self.add_module(Module.IotUsage, Usage(self, "schedule")) + self.add_module(Module.Time, Time(self, "time")) + 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" + ) + self.add_module(Module.Energy, StripEmeter(self, self.emeter_type)) + + @property # type: ignore + @requires_update + def is_on(self) -> bool: + """Return if any of the outlets are on.""" + return any(plug.is_on for plug in self.children) + + async def update(self, update_children: bool = True) -> None: + """Update some of the attributes. + + Needed for methods that are decorated with `requires_update`. + """ + # Super initializes modules and features + await super().update(update_children) + + initialize_children = not self.children + # Initialize the child devices during the first update. + if initialize_children: + children = self.sys_info["children"] + _LOGGER.debug("Initializing %s child sockets", len(children)) + self._children = { + f"{self.mac}_{child['id']}": IotStripPlug( + self.host, parent=self, child_id=child["id"] + ) + for child in children + } + for child in self._children.values(): + await child._initialize_modules() + + if update_children: + for plug in self.children: + if TYPE_CHECKING: + assert isinstance(plug, IotStripPlug) + await plug._update() + + if not self.features: + await self._initialize_features() + + async def _initialize_features(self) -> None: + """Initialize common features.""" + # Do not initialize features until children are created + if not self.children: + return + await super()._initialize_features() + + async def turn_on(self, **kwargs) -> dict: + """Turn the strip on.""" + 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.""" + for plug in self.children: + if plug.is_on: + await plug.turn_off() + return {} + + @property # type: ignore + @requires_update + def on_since(self) -> datetime | None: + """Return the maximum on-time of all outlets.""" + if self.is_off: + return None + + return min(plug.on_since for plug in self.children if plug.on_since is not None) + + +class StripEmeter(IotModule, Energy): + """Energy module implementation to aggregate child modules.""" + + _supported = ( + Energy.ModuleFeature.CONSUMPTION_TOTAL + | Energy.ModuleFeature.PERIODIC_STATS + | Energy.ModuleFeature.VOLTAGE_CURRENT + ) + + def supports(self, module_feature: Energy.ModuleFeature) -> bool: + """Return True if module supports the feature.""" + return module_feature in self._supported + + def query(self) -> dict: + """Return the base query.""" + return {} + + @property + def current_consumption(self) -> float | None: + """Get the current power consumption in watts.""" + return sum( + v if (v := plug.modules[Module.Energy].current_consumption) else 0.0 + for plug in self._device.children + ) + + async def get_status(self) -> EmeterStatus: + """Retrieve current energy readings.""" + emeter_rt = await self._async_get_emeter_sum("get_status", {}) + # Voltage is averaged since each read will result + # in a slightly different voltage since they are not atomic + emeter_rt["voltage_mv"] = int( + emeter_rt["voltage_mv"] / len(self._device.children) + ) + return EmeterStatus(emeter_rt) + + async def get_daily_stats( + self, year: int | None = None, month: int | None = None, kwh: bool = True + ) -> dict: + """Retrieve daily statistics for a given month. + + :param year: year for which to retrieve statistics (default: this year) + :param month: month for which to retrieve statistics (default: this + month) + :param kwh: return usage in kWh (default: True) + :return: mapping of day of month to value + """ + return await self._async_get_emeter_sum( + "get_daily_stats", {"year": year, "month": month, "kwh": kwh} + ) + + async def get_monthly_stats( + self, year: int | None = None, kwh: bool = True + ) -> dict: + """Retrieve monthly statistics for a given year. + + :param year: year for which to retrieve statistics (default: this year) + :param kwh: return usage in kWh (default: True) + """ + return await self._async_get_emeter_sum( + "get_monthly_stats", {"year": year, "kwh": kwh} + ) + + async def _async_get_emeter_sum(self, func: str, kwargs: dict[str, Any]) -> dict: + """Retrieve emeter stats for a time period from children.""" + return merge_sums( + [ + await getattr(plug.modules[Module.Energy], func)(**kwargs) + for plug in self._device.children + ] + ) + + async def erase_stats(self) -> dict: + """Erase energy meter statistics for all plugs.""" + for plug in self._device.children: + await plug.modules[Module.Energy].erase_stats() + + return {} + + @property # type: ignore + def consumption_this_month(self) -> float | None: + """Return this month's energy consumption in kWh.""" + return sum( + v if (v := plug.modules[Module.Energy].consumption_this_month) else 0.0 + for plug in self._device.children + ) + + @property # type: ignore + def consumption_today(self) -> float | None: + """Return this month's energy consumption in kWh.""" + return sum( + v if (v := plug.modules[Module.Energy].consumption_today) else 0.0 + for plug in self._device.children + ) + + @property # type: ignore + def consumption_total(self) -> float | None: + """Return total energy consumption since reboot in kWh.""" + return sum( + v if (v := plug.modules[Module.Energy].consumption_total) else 0.0 + for plug in self._device.children + ) + + @property # type: ignore + def status(self) -> EmeterStatus: + """Return current energy readings.""" + emeter = merge_sums( + [plug.modules[Module.Energy].status for plug in self._device.children] + ) + # Voltage is averaged since each read will result + # in a slightly different voltage since they are not atomic + emeter["voltage_mv"] = int(emeter["voltage_mv"] / len(self._device.children)) + return EmeterStatus(emeter) + + @property + def current(self) -> float | None: + """Return the current in A.""" + return self.status.current + + @property + def voltage(self) -> float | None: + """Get the current voltage in V.""" + return self.status.voltage + + +class IotStripPlug(IotPlug): + """Representation of a single socket in a power strip. + + This allows you to use the sockets as they were SmartPlug objects. + Instead of calling an update on any of these, you should call an update + on the parent device before accessing the properties. + + The plug inherits (most of) the system information from the parent. + """ + + _parent: IotStrip + + def __init__(self, host: str, parent: IotStrip, child_id: str) -> None: + super().__init__(host) + + self._parent = parent + self.child_id = child_id + self._last_update = parent._last_update + self._set_sys_info(parent.sys_info) + self._device_type = DeviceType.StripSocket + self.protocol = parent.protocol # Must use the same connection as the parent + self._on_since: datetime | None = None + + async def _initialize_modules(self) -> None: + """Initialize modules not added in init.""" + if self.has_emeter: + self.add_module(Module.Energy, Emeter(self, self.emeter_type)) + self.add_module(Module.IotUsage, Usage(self, "schedule")) + 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.""" + self._add_feature( + Feature( + self, + id="state", + name="State", + attribute_getter="is_on", + attribute_setter="set_state", + type=Feature.Type.Switch, + category=Feature.Category.Primary, + ) + ) + self._add_feature( + Feature( + device=self, + id="on_since", + name="On since", + attribute_getter="on_since", + icon="mdi:clock", + category=Feature.Category.Info, + type=Feature.Type.Sensor, + ) + ) + + for module in self.modules.values(): + module._initialize_features() + for module_feat in module._module_features.values(): + self._add_feature(module_feat) + + async def update(self, update_children: bool = True) -> None: + """Query the device to update the data. + + Needed for properties that are decorated with `requires_update`. + """ + await self._update(update_children) + + async def _update(self, update_children: bool = True) -> None: + """Query the device to update the data. + + Internal implementation to allow patching of public update in the cli + or test framework. + """ + await self._modular_update({}) + for module in self._modules.values(): + await module._post_update_hook() + + if not self._features: + await self._initialize_features() + + def _create_request( + self, + target: str, + cmd: str, + arg: dict | None = None, + child_ids: list | None = None, + ) -> dict: + request: dict[str, Any] = { + "context": {"child_ids": [self.child_id]}, + target: {cmd: arg}, + } + return request + + async def _query_helper( + self, + target: str, + cmd: str, + arg: dict | None = None, + child_ids: list | None = None, + ) -> dict: + """Override query helper to include the child_ids.""" + return await self._parent._query_helper( + target, cmd, arg, child_ids=[self.child_id] + ) + + @property # type: ignore + @requires_update + def is_on(self) -> bool: + """Return whether device is on.""" + info = self._get_child_info() + return bool(info["state"]) + + @property # type: ignore + @requires_update + def led(self) -> bool: + """Return the state of the led. + + This is always false for subdevices. + """ + 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: + """Return unique ID for the socket. + + This is a combination of MAC and child's ID. + """ + return f"{self.mac}_{self.child_id}" + + @property # type: ignore + @requires_update + def alias(self) -> str: + """Return device name (alias).""" + info = self._get_child_info() + return info["alias"] + + @property # type: ignore + @requires_update + def next_action(self) -> dict: + """Return next scheduled(?) action.""" + info = self._get_child_info() + return info["next_action"] + + @property # type: ignore + @requires_update + def on_since(self) -> datetime | None: + """Return on-time, if available.""" + if self.is_off: + self._on_since = None + return None + + info = self._get_child_info() + on_time = info["on_time"] + + time = self._parent.time + + on_since = time - timedelta(seconds=on_time) + if not self._on_since or timedelta( + seconds=0 + ) < on_since - self._on_since > timedelta(seconds=5): + self._on_since = on_since + return self._on_since + + @property # type: ignore + @requires_update + def model(self) -> str: + """Return device model for a child socket.""" + sys_info = self._parent.sys_info + return f"Socket for {sys_info['model']}" + + def _get_child_info(self) -> dict: + """Return the subdevice information for this device.""" + for plug in self._parent.sys_info["children"]: + if plug["id"] == self.child_id: + return plug + + raise KasaException( + f"Unable to find children {self.child_id}" + ) # pragma: no cover diff --git a/kasa/iot/iottimezone.py b/kasa/iot/iottimezone.py new file mode 100644 index 000000000..461540891 --- /dev/null +++ b/kasa/iot/iottimezone.py @@ -0,0 +1,275 @@ +"""Module for io device timezone lookups.""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime, timedelta, timezone, tzinfo +from typing import cast +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from ..cachedzoneinfo import CachedZoneInfo + +_LOGGER = logging.getLogger(__name__) + + +async def get_timezone(index: int) -> tzinfo: + """Get the timezone from the index.""" + if index < 0 or index > 109: + _LOGGER.error( + "Unexpected index %s not configured as a timezone, defaulting to UTC", index + ) + return await CachedZoneInfo.get_cached_zone_info("Etc/UTC") + + name = TIMEZONE_INDEX[index] + return await CachedZoneInfo.get_cached_zone_info(name) + + +async def get_timezone_index(tzone: tzinfo) -> int: + """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()} + if name in rev: + return rev[name] + + for i in range(110): + try: + cand = await get_timezone(i) + except ZoneInfoNotFoundError: + continue + if _is_same_timezone(tzone, cand): + return i + raise ValueError( + f"Device does not support timezone {getattr(tzone, 'key', tzone)!r}" + ) + + +async def get_matching_timezones(tzone: tzinfo) -> list[str]: + """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()} + if name in vals: + matches.append(name) + + for i in range(110): + 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: + matches.append(match_key) + return matches + + +def _is_same_timezone(tzone1: tzinfo, tzone2: tzinfo) -> bool: + """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): + the_day = start_day + timedelta(days=i) + if tzone1.utcoffset(the_day) != tzone2.utcoffset(the_day): + return False + 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", + 2: "US/Hawaii", + 3: "US/Alaska", + 4: "Mexico/BajaNorte", + 5: "Etc/GMT+8", + 6: "PST8PDT", + 7: "US/Arizona", + 8: "America/Mazatlan", + 9: "MST", + 10: "MST7MDT", + 11: "Mexico/General", + 12: "Etc/GMT+6", + 13: "CST6CDT", + 14: "America/Monterrey", + 15: "Canada/Saskatchewan", + 16: "America/Bogota", + 17: "Etc/GMT+5", + 18: "EST", + 19: "America/Indiana/Indianapolis", + 20: "America/Caracas", + 21: "America/Asuncion", + 22: "Etc/GMT+4", + 23: "Canada/Atlantic", + 24: "America/Cuiaba", + 25: "Brazil/West", + 26: "America/Santiago", + 27: "Canada/Newfoundland", + 28: "America/Sao_Paulo", + 29: "America/Argentina/Buenos_Aires", + 30: "America/Cayenne", + 31: "America/Miquelon", + 32: "America/Montevideo", + 33: "Chile/Continental", + 34: "Etc/GMT+2", + 35: "Atlantic/Azores", + 36: "Atlantic/Cape_Verde", + 37: "Africa/Casablanca", + 38: "UCT", + 39: "GB", + 40: "Africa/Monrovia", + 41: "Europe/Amsterdam", + 42: "Europe/Belgrade", + 43: "Europe/Brussels", + 44: "Europe/Sarajevo", + 45: "Africa/Lagos", + 46: "Africa/Windhoek", + 47: "Asia/Amman", + 48: "Europe/Athens", + 49: "Asia/Beirut", + 50: "Africa/Cairo", + 51: "Asia/Damascus", + 52: "EET", + 53: "Africa/Harare", + 54: "Europe/Helsinki", + 55: "Asia/Istanbul", + 56: "Asia/Jerusalem", + 57: "Europe/Kaliningrad", + 58: "Africa/Tripoli", + 59: "Asia/Baghdad", + 60: "Asia/Kuwait", + 61: "Europe/Minsk", + 62: "Europe/Moscow", + 63: "Africa/Nairobi", + 64: "Asia/Tehran", + 65: "Asia/Muscat", + 66: "Asia/Baku", + 67: "Europe/Samara", + 68: "Indian/Mauritius", + 69: "Asia/Tbilisi", + 70: "Asia/Yerevan", + 71: "Asia/Kabul", + 72: "Asia/Ashgabat", + 73: "Asia/Yekaterinburg", + 74: "Asia/Karachi", + 75: "Asia/Kolkata", + 76: "Asia/Colombo", + 77: "Asia/Kathmandu", + 78: "Asia/Almaty", + 79: "Asia/Dhaka", + 80: "Asia/Novosibirsk", + 81: "Asia/Rangoon", + 82: "Asia/Bangkok", + 83: "Asia/Krasnoyarsk", + 84: "Asia/Chongqing", + 85: "Asia/Irkutsk", + 86: "Asia/Singapore", + 87: "Australia/Perth", + 88: "Asia/Taipei", + 89: "Asia/Ulaanbaatar", + 90: "Asia/Tokyo", + 91: "Asia/Seoul", + 92: "Asia/Yakutsk", + 93: "Australia/Adelaide", + 94: "Australia/Darwin", + 95: "Australia/Brisbane", + 96: "Australia/Canberra", + 97: "Pacific/Guam", + 98: "Australia/Hobart", + 99: "Antarctica/DumontDUrville", + 100: "Asia/Magadan", + 101: "Asia/Srednekolymsk", + 102: "Etc/GMT-11", + 103: "Asia/Anadyr", + 104: "Pacific/Auckland", + 105: "Etc/GMT-12", + 106: "Pacific/Fiji", + 107: "Etc/GMT-13", + 108: "Pacific/Apia", + 109: "Etc/GMT-14", +} diff --git a/kasa/iot/modules/__init__.py b/kasa/iot/modules/__init__.py new file mode 100644 index 000000000..207839e4b --- /dev/null +++ b/kasa/iot/modules/__init__.py @@ -0,0 +1,39 @@ +"""Module for individual feature modules.""" + +from .ambientlight import AmbientLight +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 +from .lightpreset import IotLightPreset, LightPreset +from .motion import Motion +from .rulemodule import Rule, RuleModule +from .schedule import Schedule +from .time import Time +from .usage import Usage + +__all__ = [ + "AmbientLight", + "Antitheft", + "Cloud", + "Countdown", + "Dimmer", + "Emeter", + "Led", + "Light", + "LightEffect", + "LightPreset", + "IotLightPreset", + "Motion", + "Rule", + "RuleModule", + "Schedule", + "Time", + "Usage", + "HomeKit", +] diff --git a/kasa/iot/modules/ambientlight.py b/kasa/iot/modules/ambientlight.py new file mode 100644 index 000000000..ac5c3488c --- /dev/null +++ b/kasa/iot/modules/ambientlight.py @@ -0,0 +1,93 @@ +"""Implementation of the ambient light (LAS) module found in some dimmers.""" + +import logging + +from ...feature import Feature +from ..iotmodule import IotModule, merge + +_LOGGER = logging.getLogger(__name__) + + +class AmbientLight(IotModule): + """Implements ambient light controls for the motion sensor.""" + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + device=self._device, + container=self, + id="ambient_light_enabled", + name="Ambient light enabled", + icon="mdi:brightness-percent", + attribute_getter="enabled", + attribute_setter="set_enabled", + type=Feature.Type.Switch, + category=Feature.Category.Config, + ) + ) + self._add_feature( + Feature( + device=self._device, + container=self, + id="ambient_light", + name="Ambient Light", + icon="mdi:brightness-percent", + attribute_getter="ambientlight_brightness", + type=Feature.Type.Sensor, + category=Feature.Category.Primary, + unit_getter=lambda: "%", + ) + ) + + def query(self) -> dict: + """Request configuration.""" + req = merge( + self.query_for_command("get_config"), + self.query_for_command("get_current_brt"), + ) + + return req + + @property + def config(self) -> dict: + """Return current ambient light config.""" + config = self.data["get_config"] + devs = config["devs"] + if len(devs) != 1: + _LOGGER.error("Unexpected number of devs in config: %s", config) + + return devs[0] + + @property + def presets(self) -> dict: + """Return device-defined presets for brightness setting.""" + return self.config["level_array"] + + @property + def enabled(self) -> bool: + """Return True if the module is enabled.""" + return bool(self.config["enable"]) + + @property + def ambientlight_brightness(self) -> int: + """Return True if the module is enabled.""" + return int(self.data["get_current_brt"]["value"]) + + async def set_enabled(self, state: bool) -> dict: + """Enable/disable LAS.""" + return await self.call("set_enable", {"enable": int(state)}) + + async def current_brightness(self) -> dict: + """Return current brightness. + + Return value units. + """ + return await self.call("get_current_brt") + + async def set_brightness_limit(self, value: int) -> dict: + """Set the limit when the motion sensor is inactive. + + See `presets` for preset values. Custom values are also likely allowed. + """ + return await self.call("set_brt_level", {"index": 0, "value": value}) diff --git a/kasa/iot/modules/antitheft.py b/kasa/iot/modules/antitheft.py new file mode 100644 index 000000000..07d94b9d4 --- /dev/null +++ b/kasa/iot/modules/antitheft.py @@ -0,0 +1,10 @@ +"""Implementation of the antitheft module.""" + +from .rulemodule import RuleModule + + +class Antitheft(RuleModule): + """Implementation of the antitheft module. + + This shares the functionality among other rule-based modules. + """ diff --git a/kasa/iot/modules/cloud.py b/kasa/iot/modules/cloud.py new file mode 100644 index 000000000..d4e91a071 --- /dev/null +++ b/kasa/iot/modules/cloud.py @@ -0,0 +1,77 @@ +"""Cloud module implementation.""" + +from dataclasses import dataclass +from typing import Annotated + +from mashumaro import DataClassDictMixin +from mashumaro.types import Alias + +from ...feature import Feature +from ..iotmodule import IotModule + + +@dataclass +class CloudInfo(DataClassDictMixin): + """Container for cloud settings.""" + + provisioned: Annotated[int, Alias("binded")] + cloud_connected: Annotated[int, Alias("cld_connection")] + firmware_download_page: Annotated[str, Alias("fwDlPage")] + firmware_notify_type: Annotated[int, Alias("fwNotifyType")] + illegal_type: Annotated[int, Alias("illegalType")] + server: str + stop_connect: Annotated[int, Alias("stopConnect")] + tcsp_info: Annotated[str, Alias("tcspInfo")] + tcsp_status: Annotated[int, Alias("tcspStatus")] + username: str + + +class Cloud(IotModule): + """Module implementing support for cloud services.""" + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + device=self._device, + container=self, + id="cloud_connection", + name="Cloud connection", + icon="mdi:cloud", + attribute_getter="is_connected", + type=Feature.Type.BinarySensor, + category=Feature.Category.Info, + ) + ) + + @property + def is_connected(self) -> bool: + """Return true if device is connected to the cloud.""" + return bool(self.info.cloud_connected) + + def query(self) -> dict: + """Request cloud connectivity info.""" + return self.query_for_command("get_info") + + @property + def info(self) -> CloudInfo: + """Return information about the cloud connectivity.""" + return CloudInfo.from_dict(self.data["get_info"]) + + def get_available_firmwares(self) -> dict: + """Return list of available firmwares.""" + return self.query_for_command("get_intl_fw_list") + + def set_server(self, url: str) -> dict: + """Set the update server URL.""" + return self.query_for_command("set_server_url", {"server": url}) + + def connect(self, username: str, password: str) -> dict: + """Login to the cloud using given information.""" + return self.query_for_command( + "bind", {"username": username, "password": password} + ) + + def disconnect(self) -> dict: + """Disconnect from the cloud.""" + return self.query_for_command("unbind") diff --git a/kasa/iot/modules/countdown.py b/kasa/iot/modules/countdown.py new file mode 100644 index 000000000..d1d5c23e5 --- /dev/null +++ b/kasa/iot/modules/countdown.py @@ -0,0 +1,7 @@ +"""Implementation for the countdown timer.""" + +from .rulemodule import RuleModule + + +class Countdown(RuleModule): + """Implementation of countdown module.""" 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/emeter.py b/kasa/iot/modules/emeter.py new file mode 100644 index 000000000..012bda04c --- /dev/null +++ b/kasa/iot/modules/emeter.py @@ -0,0 +1,155 @@ +"""Implementation of the emeter module.""" + +from __future__ import annotations + +from datetime import datetime + +from ...emeterstatus import EmeterStatus +from ...interfaces.energy import Energy as EnergyInterface +from .usage import Usage + + +class Emeter(Usage, EnergyInterface): + """Emeter module.""" + + async def _post_update_hook(self) -> None: + self._supported = EnergyInterface.ModuleFeature.PERIODIC_STATS + if ( + "voltage_mv" in self.data["get_realtime"] + or "voltage" in self.data["get_realtime"] + ): + self._supported = ( + self._supported | EnergyInterface.ModuleFeature.VOLTAGE_CURRENT + ) + if ( + "total_wh" in self.data["get_realtime"] + or "total" in self.data["get_realtime"] + ): + self._supported = ( + self._supported | EnergyInterface.ModuleFeature.CONSUMPTION_TOTAL + ) + + @property # type: ignore + def status(self) -> EmeterStatus: + """Return current energy readings.""" + return EmeterStatus(self.data["get_realtime"]) + + @property + def consumption_today(self) -> float | None: + """Return today's energy consumption in kWh.""" + raw_data = self.daily_data + today = datetime.now().day + data = self._convert_stat_data(raw_data, entry_key="day", key=today) + return data.get(today, 0.0) + + @property + def consumption_this_month(self) -> float | None: + """Return this month's energy consumption in kWh.""" + raw_data = self.monthly_data + current_month = datetime.now().month + data = self._convert_stat_data(raw_data, entry_key="month", key=current_month) + return data.get(current_month, 0.0) + + @property + def current_consumption(self) -> float | None: + """Get the current power consumption in Watt.""" + return self.status.power + + @property + def consumption_total(self) -> float | None: + """Return total consumption since last reboot in kWh.""" + return self.status.total + + @property + def current(self) -> float | None: + """Return the current in A.""" + return self.status.current + + @property + def voltage(self) -> float | None: + """Get the current voltage in V.""" + return self.status.voltage + + async def erase_stats(self) -> dict: + """Erase all stats. + + Uses different query than usage meter. + """ + return await self.call("erase_emeter_stat") + + async def get_status(self) -> EmeterStatus: + """Return real-time statistics.""" + return EmeterStatus(await self.call("get_realtime")) + + async def get_daily_stats( + self, *, year: int | None = None, month: int | None = None, kwh: bool = True + ) -> dict: + """Return daily stats for the given year & month. + + The return value is a dictionary of {day: energy, ...}. + """ + data = await self.get_raw_daystat(year=year, month=month) + data = self._convert_stat_data(data["day_list"], entry_key="day", kwh=kwh) + return data + + async def get_monthly_stats( + self, *, year: int | None = None, kwh: bool = True + ) -> dict: + """Return monthly stats for the given year. + + The return value is a dictionary of {month: energy, ...}. + """ + data = await self.get_raw_monthstat(year=year) + data = self._convert_stat_data(data["month_list"], entry_key="month", kwh=kwh) + return data + + def _convert_stat_data( + self, + data: list[dict[str, int | float]], + entry_key: str, + kwh: bool = True, + key: int | None = None, + ) -> dict[int | float, int | float]: + """Return emeter information keyed with the day/month. + + The incoming data is a list of dictionaries:: + + [{'year': int, + 'month': int, + 'day': int, <-- for get_daystat not get_monthstat + 'energy_wh': int, <-- for emeter in some versions (wh) + 'energy': float <-- for emeter in other versions (kwh) + }, ...] + + :return: a dictionary keyed by day or month with energy as the value. + """ + if not data: + return {} + + scale: float = 1 + + if "energy_wh" in data[0]: + value_key = "energy_wh" + if kwh: + scale = 1 / 1000 + else: + value_key = "energy" + if not kwh: + scale = 1000 + + if key is None: + # Return all the data + return {entry[entry_key]: entry[value_key] * scale for entry in data} + + # In this case we want a specific key in the data + # i.e. the current day or month. + # + # Since we usually want the data at the end of the list so we can + # optimize the search by starting at the end and avoid scaling + # the data we don't need. + # + for entry in reversed(data): + if entry[entry_key] == key: + return {entry[entry_key]: entry[value_key] * scale} + + return {} 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/led.py b/kasa/iot/modules/led.py new file mode 100644 index 000000000..8a5727b05 --- /dev/null +++ b/kasa/iot/modules/led.py @@ -0,0 +1,37 @@ +"""Module for led controls.""" + +from __future__ import annotations + +from ...interfaces.led import Led as LedInterface +from ..iotmodule import IotModule + + +class Led(IotModule, LedInterface): + """Implementation of led controls.""" + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + + @property + def mode(self) -> str: + """LED mode setting. + + "always", "never" + """ + return "always" if self.led else "never" + + @property + def led(self) -> bool: + """Return the state of the led.""" + sys_info = self.data + return bool(1 - sys_info["led_off"]) + + async def set_led(self, state: bool) -> dict: + """Set the state of the led (night mode).""" + return await self.call("set_led_off", {"off": int(not state)}) + + @property + def is_supported(self) -> bool: + """Return whether the module is supported by the device.""" + return "led_off" in self.data diff --git a/kasa/iot/modules/light.py b/kasa/iot/modules/light.py new file mode 100644 index 000000000..fa9535908 --- /dev/null +++ b/kasa/iot/modules/light.py @@ -0,0 +1,232 @@ +"""Implementation of brightness module.""" + +from __future__ import annotations + +from dataclasses import asdict +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, LightState +from ...interfaces.light import Light as LightInterface +from ...module import FeatureAttribute +from ..iotmodule import IotModule + +if TYPE_CHECKING: + from ..iotbulb import IotBulb + from ..iotdimmer import IotDimmer + + +BRIGHTNESS_MIN = 0 +BRIGHTNESS_MAX = 100 + + +class Light(IotModule, LightInterface): + """Implementation of brightness module.""" + + _device: IotBulb | IotDimmer + _light_state: LightState + + def _initialize_features(self) -> None: + """Initialize features.""" + super()._initialize_features() + device = self._device + + if device._is_dimmable: + self._add_feature( + Feature( + device, + id="brightness", + name="Brightness", + container=self, + attribute_getter="brightness", + attribute_setter="set_brightness", + range_getter=lambda: (BRIGHTNESS_MIN, BRIGHTNESS_MAX), + type=Feature.Type.Number, + category=Feature.Category.Primary, + ) + ) + if device._is_variable_color_temp: + if TYPE_CHECKING: + assert isinstance(device, IotBulb) + self._add_feature( + Feature( + device=device, + id="color_temperature", + name="Color temperature", + container=self, + attribute_getter="color_temp", + attribute_setter="set_color_temp", + range_getter=lambda: device._valid_temperature_range, + category=Feature.Category.Primary, + type=Feature.Type.Number, + ) + ) + if device._is_color: + self._add_feature( + Feature( + device=device, + id="hsv", + name="HSV", + container=self, + attribute_getter="hsv", + attribute_setter="set_hsv", + # TODO proper type for setting hsv + type=Feature.Type.Unknown, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + # Brightness is contained in the main device info response. + return {} + + def _get_bulb_device(self) -> IotBulb | None: + """For type checker this gets an IotBulb. + + IotDimmer is not a subclass of IotBulb and using isinstance + here at runtime would create a circular import. + """ + if self._device.device_type in {DeviceType.Bulb, DeviceType.LightStrip}: + return cast("IotBulb", self._device) + return None + + @property # type: ignore + 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 + ) -> Annotated[dict, FeatureAttribute()]: + """Set the brightness in percentage. A value of 0 will turn off the light. + + :param int brightness: brightness in percent + :param int transition: transition in milliseconds. + """ + return await self.set_state( + LightState(brightness=brightness, transition=transition) + ) + + @property + def hsv(self) -> Annotated[HSV, FeatureAttribute()]: + """Return the current HSV state of the bulb. + + :return: hue, saturation and value (degrees, %, %) + """ + if (bulb := self._get_bulb_device()) is None or not bulb._is_color: + raise KasaException("Light does not support color.") + return bulb._hsv + + async def set_hsv( + self, + hue: int, + saturation: int, + value: int | None = None, + *, + transition: int | None = None, + ) -> Annotated[dict, FeatureAttribute()]: + """Set new HSV. + + Note, transition is not supported and will be ignored. + + :param int hue: hue in degrees + :param int saturation: saturation in percentage [0,100] + :param int value: value in percentage [0, 100] + :param int transition: transition in milliseconds. + """ + if (bulb := self._get_bulb_device()) is None or not bulb._is_color: + raise KasaException("Light does not support color.") + return await bulb._set_hsv(hue, saturation, value, transition=transition) + + @property + def color_temp(self) -> Annotated[int, FeatureAttribute()]: + """Whether the bulb supports color temperature changes.""" + if ( + bulb := self._get_bulb_device() + ) is None or not bulb._is_variable_color_temp: + raise KasaException("Light does not support colortemp.") + return bulb._color_temp + + async def set_color_temp( + self, temp: int, *, brightness: int | None = None, transition: int | None = None + ) -> Annotated[dict, FeatureAttribute()]: + """Set the color temperature of the device in kelvin. + + Note, transition is not supported and will be ignored. + + :param int temp: The new color temperature, in Kelvin + :param int transition: transition in milliseconds. + """ + if ( + bulb := self._get_bulb_device() + ) is None or not bulb._is_variable_color_temp: + raise KasaException("Light does not support colortemp.") + return await bulb._set_color_temp( + temp, brightness=brightness, transition=transition + ) + + async def set_state(self, state: LightState) -> dict: + """Set the light state.""" + # iot protocol Dimmers and smart protocol devices do not support + # brightness of 0 so 0 will turn off all devices for consistency + if (bulb := self._get_bulb_device()) is None: # Dimmer + if TYPE_CHECKING: + assert isinstance(self._device, IotDimmer) + if state.brightness == 0 or state.light_on is False: + return await self._device.turn_off(transition=state.transition) + elif state.brightness: + # set_dimmer_transition will turn on the device + return await self._device.set_dimmer_transition( + state.brightness, state.transition or 0 + ) + return await self._device.turn_on(transition=state.transition) + else: + transition = state.transition + state_dict = asdict(state) + state_dict = {k: v for k, v in state_dict.items() if v is not None} + if "transition" in state_dict: + del state_dict["transition"] + state_dict["on_off"] = 1 if state.light_on is None else int(state.light_on) + if state_dict.get("brightness") == 0: + state_dict["on_off"] = 0 + del state_dict["brightness"] + # If light on state not set default to on. + elif state.light_on is None: + state_dict["on_off"] = 1 + else: + state_dict["on_off"] = int(state.light_on) + # Remove the light_on from the dict + state_dict.pop("light_on", None) + return await bulb._set_light_state(state_dict, transition=transition) + + @property + def state(self) -> LightState: + """Return the current light state.""" + return self._light_state + + async def _post_update_hook(self) -> None: + device = self._device + if device.is_on is False: + state = LightState(light_on=False) + else: + state = LightState(light_on=True) + if device._is_dimmable: + state.brightness = self.brightness + if device._is_color: + hsv = self.hsv + state.hue = hsv.hue + state.saturation = hsv.saturation + if device._is_variable_color_temp: + state.color_temp = self.color_temp + self._light_state = state + + async def _deprecated_set_light_state( + self, state: dict, *, transition: int | None = None + ) -> dict: + """Set the light state.""" + if (bulb := self._get_bulb_device()) is None: + raise KasaException("Device does not support set_light_state") + else: + return await bulb._set_light_state(state, transition=transition) diff --git a/kasa/iot/modules/lighteffect.py b/kasa/iot/modules/lighteffect.py new file mode 100644 index 000000000..3a41fb5f6 --- /dev/null +++ b/kasa/iot/modules/lighteffect.py @@ -0,0 +1,126 @@ +"""Module for light effects.""" + +from __future__ import annotations + +from ...interfaces.lighteffect import LightEffect as LightEffectInterface +from ..effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 +from ..iotmodule import IotModule + + +class LightEffect(IotModule, LightEffectInterface): + """Implementation of dynamic light effects.""" + + @property + def effect(self) -> str: + """Return effect name.""" + eff = self.data["lighting_effect_state"] + name = eff["name"] + if eff["enable"]: + return name or self.LIGHT_EFFECTS_UNNAMED_CUSTOM + return self.LIGHT_EFFECTS_OFF + + @property + def brightness(self) -> int: + """Return light effect brightness.""" + return self.data["lighting_effect_state"]["brightness"] + + @property + def effect_list(self) -> list[str]: + """Return built-in effects list. + + Example: + ['Aurora', 'Bubbling Cauldron', ...] + """ + effect_list = [self.LIGHT_EFFECTS_OFF] + effect_list.extend(EFFECT_NAMES_V1) + return effect_list + + async def set_effect( + self, + effect: str, + *, + brightness: int | None = None, + transition: int | None = None, + ) -> dict: + """Set an effect on the device. + + If brightness or transition is defined, + its value will be used instead of the effect-specific default. + + See :meth:`effect_list` for available effects, + or use :meth:`set_custom_effect` for custom effects. + + :param str effect: The effect to set + :param int brightness: The wanted brightness + :param int transition: The wanted transition time + """ + if effect == self.LIGHT_EFFECTS_OFF: + if self.effect in EFFECT_MAPPING_V1: + # TODO: We could query get_lighting_effect here to + # get the custom effect although not sure how to find + # custom effects + effect_dict = EFFECT_MAPPING_V1[self.effect] + else: + effect_dict = EFFECT_MAPPING_V1["Aurora"] + effect_dict = {**effect_dict} + effect_dict["enable"] = 0 + return await self.set_custom_effect(effect_dict) + elif effect not in EFFECT_MAPPING_V1: + raise ValueError(f"The effect {effect} is not a built in effect.") + else: + effect_dict = EFFECT_MAPPING_V1[effect] + effect_dict = {**effect_dict} + if brightness is not None: + effect_dict["brightness"] = brightness + if transition is not None: + effect_dict["transition"] = transition + + return await self.set_custom_effect(effect_dict) + + async def set_custom_effect( + self, + effect_dict: dict, + ) -> dict: + """Set a custom effect on the device. + + :param str effect_dict: The custom effect dict to set + """ + return await self.call( + "set_lighting_effect", + effect_dict, + ) + + @property + def has_custom_effects(self) -> bool: + """Return True if the device supports setting custom effects.""" + return True + + def query(self) -> dict: + """Return the base query.""" + return {} + + @property # type: ignore + def _deprecated_effect(self) -> dict: + """Return effect state. + + Example: + {'brightness': 50, + 'custom': 0, + 'enable': 0, + 'id': '', + 'name': ''} + """ + # LightEffectModule returns the current effect name + # so return the dict here for backwards compatibility + return self.data["lighting_effect_state"] + + @property # type: ignore + def _deprecated_effect_list(self) -> list[str] | None: + """Return built-in effects list. + + Example: + ['Aurora', 'Bubbling Cauldron', ...] + """ + # LightEffectModule returns effect names along with a LIGHT_EFFECTS_OFF value + # so return the original effect names here for backwards compatibility + return EFFECT_NAMES_V1 diff --git a/kasa/iot/modules/lightpreset.py b/kasa/iot/modules/lightpreset.py new file mode 100644 index 000000000..3330af69f --- /dev/null +++ b/kasa/iot/modules/lightpreset.py @@ -0,0 +1,169 @@ +"""Light preset module.""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import asdict, dataclass +from typing import TYPE_CHECKING + +from mashumaro.config import BaseConfig + +from ...exceptions import KasaException +from ...interfaces import LightPreset as LightPresetInterface +from ...interfaces import LightState +from ...json import DataClassJSONMixin +from ...module import Module +from ..iotmodule import IotModule + +if TYPE_CHECKING: + pass + +# type ignore can be removed after migration mashumaro: +# error: Signature of "__replace__" incompatible with supertype "LightState" + + +@dataclass(kw_only=True, repr=False) +class IotLightPreset(DataClassJSONMixin, LightState): # type: ignore[override] + """Light configuration preset.""" + + class Config(BaseConfig): + """Config class.""" + + omit_none = True + + index: int + brightness: int + + # These are not available for effect mode presets on light strips + hue: int | None = None + saturation: int | None = None + color_temp: int | None = None + + # Variables for effect mode presets + custom: int | None = None + id: str | None = None + mode: int | None = None + + +class LightPreset(IotModule, LightPresetInterface): + """Class for setting light presets.""" + + _presets: dict[str, IotLightPreset] + _preset_list: list[str] + + async def _post_update_hook(self) -> None: + """Update the internal presets.""" + self._presets = { + 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 + if "id" not in vals + } + self._preset_list = [self.PRESET_NOT_SET] + self._preset_list.extend(self._presets.keys()) + + @property + def preset_list(self) -> list[str]: + """Return built-in effects list. + + Example: + ['Off', 'Preset 1', 'Preset 2', ...] + """ + return self._preset_list + + @property + def preset_states_list(self) -> Sequence[IotLightPreset]: + """Return built-in effects list. + + Example: + ['Off', 'Preset 1', 'Preset 2', ...] + """ + return list(self._presets.values()) + + @property + 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 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 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 + + async def set_preset( + self, + preset_name: str, + ) -> dict: + """Set a light preset for the device.""" + light = self._device.modules[Module.Light] + if preset_name == self.PRESET_NOT_SET: + if light.has_feature("hsv"): + preset = LightState(hue=0, saturation=0, brightness=100) + else: + preset = LightState(brightness=100) + elif (preset := self._presets.get(preset_name)) is None: # type: ignore[assignment] + raise ValueError(f"{preset_name} is not a valid preset: {self.preset_list}") + + return await light.set_state(preset) + + @property + def has_save_preset(self) -> bool: + """Return True if the device supports updating presets.""" + return True + + async def save_preset( + self, + preset_name: str, + preset_state: LightState, + ) -> dict: + """Update the preset with preset_name with the new preset_info.""" + if len(self._presets) == 0: + raise KasaException("Device does not supported saving presets") + if preset_name not in self._presets: + raise ValueError(f"{preset_name} is not a valid preset: {self.preset_list}") + + index = list(self._presets.keys()).index(preset_name) + state = asdict(preset_state) + state = {k: v for k, v in state.items() if v is not None} + state["index"] = index + + return await self.call("set_preferred_state", state) + + def query(self) -> dict: + """Return the base query.""" + return {} + + @property # type: ignore + def _deprecated_presets(self) -> list[IotLightPreset]: + """Return a list of available bulb setting presets.""" + return [ + IotLightPreset(**vals) + for vals in self._device.sys_info["preferred_state"] + if "id" not in vals + ] + + async def _deprecated_save_preset(self, preset: IotLightPreset) -> dict: + """Save a setting preset. + + You can either construct a preset object manually, or pass an existing one + obtained using :func:`presets`. + """ + if len(self._presets) == 0: + raise KasaException("Device does not supported saving presets") + + if preset.index >= len(self._presets): + raise KasaException("Invalid preset index") + + return await self.call("set_preferred_state", preset.to_dict()) diff --git a/kasa/iot/modules/motion.py b/kasa/iot/modules/motion.py new file mode 100644 index 000000000..a795b449a --- /dev/null +++ b/kasa/iot/modules/motion.py @@ -0,0 +1,408 @@ +"""Implementation of the motion detection (PIR) module found in some dimmers.""" + +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, merge + +_LOGGER = logging.getLogger(__name__) + + +class Range(Enum): + """Range for motion detection.""" + + Far = 0 + Mid = 1 + 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.""" + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + # Only add features if the device supports the module + 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 + + self._add_feature( + Feature( + device=self._device, + container=self, + id="pir_enabled", + name="PIR enabled", + icon="mdi:motion-sensor", + attribute_getter="enabled", + attribute_setter="set_enabled", + type=Feature.Type.Switch, + category=Feature.Category.Config, + ) + ) + + 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.""" + req = merge( + self.query_for_command("get_config"), + self.query_for_command("get_adc_value"), + ) + + return req + + @property + def config(self) -> dict: + """Return current configuration.""" + return self.data["get_config"] + + @property + 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 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)}) + + @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 + + async def set_range(self, range: Range) -> dict: + """Set the Range for the sensor. + + :param Range: the range class to use. + """ + 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 + def inactivity_timeout(self) -> int: + """Return inactivity timeout in milliseconds.""" + return self.config["cold_time"] + + async def set_inactivity_timeout(self, timeout: int) -> dict: + """Set inactivity timeout in milliseconds. + + Note, that you need to delete the default "Smart Control" rule in the app + 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/rulemodule.py b/kasa/iot/modules/rulemodule.py new file mode 100644 index 000000000..ba08b366b --- /dev/null +++ b/kasa/iot/modules/rulemodule.py @@ -0,0 +1,87 @@ +"""Base implementation for all rule-based modules.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from enum import Enum + +from mashumaro import DataClassDictMixin + +from ..iotmodule import IotModule, merge + + +class Action(Enum): + """Action to perform.""" + + Disabled = -1 + TurnOff = 0 + TurnOn = 1 + Unknown = 2 + + +class TimeOption(Enum): + """Time when the action is executed.""" + + Disabled = -1 + Enabled = 0 + AtSunrise = 1 + AtSunset = 2 + + +@dataclass +class Rule(DataClassDictMixin): + """Representation of a rule.""" + + id: str + name: str + enable: int + wday: list[int] + repeat: int + + # start action + sact: Action | None = None + stime_opt: TimeOption | None = None + smin: int | None = None + + eact: Action | None = None + etime_opt: TimeOption | None = None + emin: int | None = None + + # Only on bulbs + s_light: dict | None = None + + +_LOGGER = logging.getLogger(__name__) + + +class RuleModule(IotModule): + """Base class for rule-based modules, such as countdown and antitheft.""" + + def query(self) -> dict: + """Prepare the query for rules.""" + q = self.query_for_command("get_rules") + return merge(q, self.query_for_command("get_next_action")) + + @property + def rules(self) -> list[Rule]: + """Return the list of rules for the service.""" + try: + return [ + Rule.from_dict(rule) for rule in self.data["get_rules"]["rule_list"] + ] + except Exception as ex: + _LOGGER.error("Unable to read rule list: %s (data: %s)", ex, self.data) + return [] + + async def set_enabled(self, state: bool) -> dict: + """Enable or disable the service.""" + return await self.call("set_overall_enable", {"enable": state}) + + async def delete_rule(self, rule: Rule) -> dict: + """Delete the given rule.""" + return await self.call("delete_rule", {"id": rule.id}) + + async def delete_all_rules(self) -> dict: + """Delete all rules.""" + return await self.call("delete_all_rules") diff --git a/kasa/iot/modules/schedule.py b/kasa/iot/modules/schedule.py new file mode 100644 index 000000000..fe881951c --- /dev/null +++ b/kasa/iot/modules/schedule.py @@ -0,0 +1,7 @@ +"""Schedule module implementation.""" + +from .rulemodule import RuleModule + + +class Schedule(RuleModule): + """Implements the scheduling interface.""" diff --git a/kasa/iot/modules/time.py b/kasa/iot/modules/time.py new file mode 100644 index 000000000..a9f3704b6 --- /dev/null +++ b/kasa/iot/modules/time.py @@ -0,0 +1,137 @@ +"""Provides the current time and timezone information.""" + +from __future__ import annotations + +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 ( + _expected_dst_behavior_for_index, + _guess_timezone_by_offset, + get_timezone, + get_timezone_index, +) + + +class Time(IotModule, TimeInterface): + """Implements the timezone settings.""" + + _timezone: tzinfo = UTC + + def query(self) -> dict: + """Request time and timezone.""" + q = self.query_for_command("get_time") + + merge(q, self.query_for_command("get_timezone")) + return q + + async def _post_update_hook(self) -> None: + """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"): + 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: + """Return current device time.""" + res = self.data["get_time"] + time = datetime( + res["year"], + res["month"], + res["mday"], + res["hour"], + res["min"], + res["sec"], + tzinfo=self.timezone, + ) + return time + + @property + def timezone(self) -> tzinfo: + """Return current timezone.""" + return self._timezone + + async def get_time(self) -> datetime | None: + """Return current device time.""" + try: + res = await self.call("get_time") + return datetime( + res["year"], + res["month"], + res["mday"], + res["hour"], + res["min"], + res["sec"], + tzinfo=self.timezone, + ) + except KasaException: + return None + + async def set_time(self, dt: datetime) -> dict: + """Set the device time.""" + params = { + "year": dt.year, + "month": dt.month, + "mday": dt.day, + "hour": dt.hour, + "min": dt.minute, + "sec": dt.second, + } + if dt.tzinfo: + index = await get_timezone_index(dt.tzinfo) + current_index = self.data.get("get_timezone", {}).get("index", -1) + if current_index != -1 and current_index != index: + params["index"] = index + method = "set_timezone" + else: + method = "set_time" + else: + method = "set_time" + try: + return await self.call(method, params) + except Exception as ex: + raise KasaException(ex) from ex + + async def get_timezone(self) -> dict: + """Request timezone information from the device.""" + return await self.call("get_timezone") diff --git a/kasa/iot/modules/usage.py b/kasa/iot/modules/usage.py new file mode 100644 index 000000000..89d8cca2b --- /dev/null +++ b/kasa/iot/modules/usage.py @@ -0,0 +1,122 @@ +"""Implementation of the usage interface.""" + +from __future__ import annotations + +from datetime import datetime + +from ..iotmodule import IotModule, merge + + +class Usage(IotModule): + """Baseclass for emeter/usage interfaces.""" + + def query(self) -> dict: + """Return the base query.""" + now = datetime.now() + year = now.year + month = now.month + + req = self.query_for_command("get_realtime") + req = merge( + req, self.query_for_command("get_daystat", {"year": year, "month": month}) + ) + req = merge(req, self.query_for_command("get_monthstat", {"year": year})) + + return req + + @property + def estimated_query_response_size(self) -> int: + """Estimated maximum query response size.""" + return 2048 + + @property + def daily_data(self) -> list[dict]: + """Return statistics on daily basis.""" + return self.data["get_daystat"]["day_list"] + + @property + def monthly_data(self) -> list[dict]: + """Return statistics on monthly basis.""" + return self.data["get_monthstat"]["month_list"] + + @property + def usage_today(self) -> int | None: + """Return today's usage in minutes.""" + today = datetime.now().day + # Traverse the list in reverse order to find the latest entry. + for entry in reversed(self.daily_data): + if entry["day"] == today: + return entry["time"] + return None + + @property + def usage_this_month(self) -> int | None: + """Return usage in this month in minutes.""" + this_month = datetime.now().month + # Traverse the list in reverse order to find the latest entry. + for entry in reversed(self.monthly_data): + if entry["month"] == this_month: + return entry["time"] + return None + + async def get_raw_daystat( + self, *, year: int | None = None, month: int | None = None + ) -> dict: + """Return raw daily stats for the given year & month.""" + if year is None: + year = datetime.now().year + if month is None: + month = datetime.now().month + + return await self.call("get_daystat", {"year": year, "month": month}) + + async def get_raw_monthstat(self, *, year: int | None = None) -> dict: + """Return raw monthly stats for the given year.""" + if year is None: + year = datetime.now().year + + return await self.call("get_monthstat", {"year": year}) + + async def get_daystat( + self, *, year: int | None = None, month: int | None = None + ) -> dict: + """Return daily stats for the given year & month. + + The return value is a dictionary of {day: time, ...}. + """ + data = await self.get_raw_daystat(year=year, month=month) + data = self._convert_stat_data(data["day_list"], entry_key="day") + return data + + async def get_monthstat(self, *, year: int | None = None) -> dict: + """Return monthly stats for the given year. + + The return value is a dictionary of {month: time, ...}. + """ + data = await self.get_raw_monthstat(year=year) + data = self._convert_stat_data(data["month_list"], entry_key="month") + return data + + async def erase_stats(self) -> dict: + """Erase all stats.""" + return await self.call("erase_runtime_stat") + + def _convert_stat_data(self, data: list[dict], entry_key: str) -> dict: + """Return usage information keyed with the day/month. + + The incoming data is a list of dictionaries:: + + [{'year': int, + 'month': int, + 'day': int, <-- for get_daystat not get_monthstat + 'time': int, <-- for usage (mins) + }, ...] + + :return: return a dictionary keyed by day or month with time as the value. + """ + if not data: + return {} + + res = {entry[entry_key]: entry["time"] for entry in data} + + return res diff --git a/kasa/json.py b/kasa/json.py new file mode 100755 index 000000000..8a0eab7b4 --- /dev/null +++ b/kasa/json.py @@ -0,0 +1,40 @@ +"""JSON abstraction.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +try: + import orjson + + def dumps( + obj: Any, *, default: Callable | None = None, indent: bool = False + ) -> str: + """Dump JSON.""" + 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, indent: bool = False + ) -> str: + """Dump JSON.""" + # Separators specified for consistency with orjson + return json.dumps(obj, separators=(",", ":"), indent=2 if indent else None) + + loads = json.loads + + +try: + from mashumaro.mixins.orjson import DataClassORJSONMixin + + DataClassJSONMixin = DataClassORJSONMixin +except ImportError: + from mashumaro.mixins.json import DataClassJSONMixin as JSONMixin + + DataClassJSONMixin = JSONMixin # type: ignore[assignment, misc] diff --git a/kasa/module.py b/kasa/module.py new file mode 100644 index 000000000..097bac617 --- /dev/null +++ b/kasa/module.py @@ -0,0 +1,310 @@ +"""Interact with modules. + +Modules are implemented by devices to encapsulate sets of functionality like +Light, AutoOff, Firmware etc. + +>>> from kasa import Discover, Module +>>> +>>> dev = await Discover.discover_single( +>>> "127.0.0.3", +>>> username="user@example.com", +>>> password="great_password" +>>> ) +>>> await dev.update() +>>> print(dev.alias) +Living Room Bulb + +To see whether a device supports a group of functionality +check for the existence of the module: + +>>> if light := dev.modules.get("Light"): +>>> 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) +HSV(hue=0, saturation=100, value=100) + +If you know or expect the module to exist you can access by index: + +>>> light_preset = dev.modules["LightPreset"] +>>> print(light_preset.preset_list) +['Not set', 'Light preset 1', 'Light preset 2', 'Light preset 3',\ + 'Light preset 4', 'Light preset 5', 'Light preset 6', 'Light preset 7'] + +Modules support typing via the Module names in Module: + +>>> from typing import reveal_type, TYPE_CHECKING +>>> 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 + +""" + +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod +from collections.abc import Callable +from functools import cache +from typing import ( + TYPE_CHECKING, + Final, + TypeVar, + get_type_hints, +) + +from .exceptions import KasaException +from .feature import Feature +from .modulemapping import ModuleName + +if TYPE_CHECKING: + from . import interfaces + from .device import Device + from .iot import modules as iot + from .smart import modules as smart + from .smartcam import modules as smartcam + +_LOGGER = logging.getLogger(__name__) + +ModuleT = TypeVar("ModuleT", bound="Module") + + +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" + + +class Module(ABC): + """Base class implemention for all modules. + + The base classes should implement `query` to return the query they want to be + executed during the regular update cycle. + """ + + # 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") + Led: Final[ModuleName[interfaces.Led]] = ModuleName("Led") + Light: Final[ModuleName[interfaces.Light]] = ModuleName("Light") + LightPreset: Final[ModuleName[interfaces.LightPreset]] = ModuleName("LightPreset") + Thermostat: Final[ModuleName[interfaces.Thermostat]] = ModuleName("Thermostat") + Time: Final[ModuleName[interfaces.Time]] = ModuleName("Time") + + # IOT only Modules + 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 + AutoOff: Final[ModuleName[smart.AutoOff]] = ModuleName("AutoOff") + BatterySensor: Final[ModuleName[smart.BatterySensor]] = ModuleName("BatterySensor") + Brightness: Final[ModuleName[smart.Brightness]] = ModuleName("Brightness") + ChildDevice: Final[ModuleName[smart.ChildDevice]] = ModuleName("ChildDevice") + Cloud: Final[ModuleName[smart.Cloud]] = ModuleName("Cloud") + Color: Final[ModuleName[smart.Color]] = ModuleName("Color") + ColorTemperature: Final[ModuleName[smart.ColorTemperature]] = ModuleName( + "ColorTemperature" + ) + ContactSensor: Final[ModuleName[smart.ContactSensor]] = ModuleName("ContactSensor") + DeviceModule: Final[ModuleName[smart.DeviceModule]] = ModuleName("DeviceModule") + Firmware: Final[ModuleName[smart.Firmware]] = ModuleName("Firmware") + FrostProtection: Final[ModuleName[smart.FrostProtection]] = ModuleName( + "FrostProtection" + ) + HumiditySensor: Final[ModuleName[smart.HumiditySensor]] = ModuleName( + "HumiditySensor" + ) + LightTransition: Final[ModuleName[smart.LightTransition]] = ModuleName( + "LightTransition" + ) + MotionSensor: Final[ModuleName[smart.MotionSensor]] = ModuleName("MotionSensor") + ReportMode: Final[ModuleName[smart.ReportMode]] = ModuleName("ReportMode") + SmartLightEffect: Final[ModuleName[smart.SmartLightEffect]] = ModuleName( + "LightEffect" + ) + IotLightEffect: Final[ModuleName[iot.LightEffect]] = ModuleName("LightEffect") + TemperatureSensor: Final[ModuleName[smart.TemperatureSensor]] = ModuleName( + "TemperatureSensor" + ) + TemperatureControl: Final[ModuleName[smart.TemperatureControl]] = ModuleName( + "TemperatureControl" + ) + WaterleakSensor: Final[ModuleName[smart.WaterleakSensor]] = ModuleName( + "WaterleakSensor" + ) + 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.""" + return self._module_features + + def has_feature(self, attribute: str | property | Callable) -> bool: + """Return True if the module attribute feature is supported.""" + return bool(self.get_feature(attribute)) + + def get_feature(self, attribute: str | property | Callable) -> Feature | None: + """Get Feature for a module attribute or None if not supported.""" + return _get_bound_feature(self, attribute) + + @abstractmethod + def query(self) -> dict: + """Query to execute during the update cycle. + + The inheriting modules implement this to include their wanted + queries to the query that gets executed when Device.update() gets called. + """ + + @property + @abstractmethod + def data(self) -> dict: + """Return the module specific raw data from the last update.""" + + def _initialize_features(self) -> None: # noqa: B027 + """Initialize features after the initial update. + + This can be implemented if features depend on module query responses. + It will only be called once per module and will always be called + after *_post_update_hook* has been called for every device module and its + children's modules. + """ + + async def _post_update_hook(self) -> None: # noqa: B027 + """Perform actions after a device update. + + This can be implemented if a module needs to perform actions each time + the device has updated like generating collections for property access. + It will be called after every update and will be called prior to + *_initialize_features* on the first update. + """ + + def _add_feature(self, feature: Feature) -> None: + """Add module feature.""" + id_ = feature.id + if id_ in self._module_features: + raise KasaException(f"Duplicate id detected {id_}") + self._module_features[id_] = feature + + def __repr__(self) -> str: + return ( + f"" + ) + + +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) + else: + hints = get_type_hints(attribute, include_extras=True) + + if (return_hints := hints.get("return")) and hasattr(return_hints, "__metadata__"): + metadata = hints["return"].__metadata__ + for meta in metadata: + if isinstance(meta, FeatureAttribute): + return meta + + return None + + +@cache +def _get_bound_feature( + module: Module, attribute: str | property | Callable +) -> Feature | None: + """Get Feature for a bound property or None if not supported.""" + if not isinstance(attribute, str): + if isinstance(attribute, property): + # Properties have __name__ in 3.13 so this could be simplified + # when only 3.13 supported + attribute_name = attribute.fget.__name__ # type: ignore[union-attr] + else: + attribute_name = attribute.__name__ + attribute_callable = attribute + else: + if TYPE_CHECKING: + assert isinstance(attribute, str) + attribute_name = attribute + attribute_callable = getattr(module.__class__, attribute, None) # type: ignore[assignment] + if not attribute_callable: + raise KasaException( + f"No attribute named {attribute_name} in " + f"module {module.__class__.__name__}" + ) + + 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: + return feature + + if (setter := feature.attribute_setter) and setter in check: + return feature + + return None diff --git a/kasa/modulemapping.py b/kasa/modulemapping.py new file mode 100644 index 000000000..06ba86190 --- /dev/null +++ b/kasa/modulemapping.py @@ -0,0 +1,25 @@ +"""Module for Implementation for ModuleMapping and ModuleName types. + +Custom dict for getting typed modules from the module dict. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Generic, TypeVar + +if TYPE_CHECKING: + from .module import Module + +_ModuleT = TypeVar("_ModuleT", bound="Module") + + +class ModuleName(str, Generic[_ModuleT]): + """Generic Module name type. + + At runtime this is a generic subclass of str. + """ + + __slots__ = () + + +ModuleMapping = dict diff --git a/kasa/modulemapping.pyi b/kasa/modulemapping.pyi new file mode 100644 index 000000000..a49de389d --- /dev/null +++ b/kasa/modulemapping.pyi @@ -0,0 +1,94 @@ +"""Typing stub file for ModuleMapping.""" + +from abc import ABCMeta +from collections.abc import Mapping +from typing import Generic, TypeVar, overload + +from .module import Module + +__all__ = [ + "ModuleMapping", + "ModuleName", +] + +_ModuleT = TypeVar("_ModuleT", bound=Module, covariant=True) +_ModuleBaseT = TypeVar("_ModuleBaseT", bound=Module, covariant=True) + +class ModuleName(Generic[_ModuleT]): + """Class for typed Module names. At runtime delegated to str.""" + + def __init__(self, value: str, /) -> None: ... + def __len__(self) -> int: ... + def __hash__(self) -> int: ... + def __eq__(self, other: object) -> bool: ... + def __getitem__(self, index: int) -> str: ... + +class ModuleMapping( + Mapping[ModuleName[_ModuleBaseT] | str, _ModuleBaseT], metaclass=ABCMeta +): + """Custom dict type to provide better value type hints for Module key types.""" + + @overload + def __getitem__(self, key: ModuleName[_ModuleT], /) -> _ModuleT: ... + @overload + def __getitem__(self, key: str, /) -> _ModuleBaseT: ... + @overload + def __getitem__( + self, key: ModuleName[_ModuleT] | str, / + ) -> _ModuleT | _ModuleBaseT: ... + @overload # type: ignore[override] + def get(self, key: ModuleName[_ModuleT], /) -> _ModuleT | None: ... + @overload + def get(self, key: str, /) -> _ModuleBaseT | None: ... + @overload + def get( + self, key: ModuleName[_ModuleT] | str, / + ) -> _ModuleT | _ModuleBaseT | None: ... + +def _test_module_mapping_typing() -> None: + """Test ModuleMapping overloads work as intended. + + This is tested during the mypy run and needs to be in this file. + """ + from typing import Any, NewType, assert_type, cast + + from .iot.iotmodule import IotModule + from .module import Module + from .smart.smartmodule import SmartModule + + NewCommonModule = NewType("NewCommonModule", Module) + NewIotModule = NewType("NewIotModule", IotModule) + NewSmartModule = NewType("NewSmartModule", SmartModule) + NotModule = NewType("NotModule", list) + + NEW_COMMON_MODULE: ModuleName[NewCommonModule] = ModuleName("NewCommonModule") + NEW_IOT_MODULE: ModuleName[NewIotModule] = ModuleName("NewIotModule") + NEW_SMART_MODULE: ModuleName[NewSmartModule] = ModuleName("NewSmartModule") + + # TODO Enable --warn-unused-ignores + NOT_MODULE: ModuleName[NotModule] = ModuleName("NotModule") # type: ignore[type-var] # noqa: F841 + NOT_MODULE_2 = ModuleName[NotModule]("NotModule2") # type: ignore[type-var] # noqa: F841 + + device_modules: ModuleMapping[Module] = cast(ModuleMapping[Module], {}) + assert_type(device_modules[NEW_COMMON_MODULE], NewCommonModule) + assert_type(device_modules[NEW_IOT_MODULE], NewIotModule) + assert_type(device_modules[NEW_SMART_MODULE], NewSmartModule) + assert_type(device_modules["foobar"], Module) + assert_type(device_modules[3], Any) # type: ignore[call-overload] + + assert_type(device_modules.get(NEW_COMMON_MODULE), NewCommonModule | None) + assert_type(device_modules.get(NEW_IOT_MODULE), NewIotModule | None) + assert_type(device_modules.get(NEW_SMART_MODULE), NewSmartModule | None) + assert_type(device_modules.get(NEW_COMMON_MODULE, default=[1, 2]), Any) # type: ignore[call-overload] + + iot_modules: ModuleMapping[IotModule] = cast(ModuleMapping[IotModule], {}) + smart_modules: ModuleMapping[SmartModule] = cast(ModuleMapping[SmartModule], {}) + + assert_type(smart_modules["foobar"], SmartModule) + assert_type(iot_modules["foobar"], IotModule) + + # Test for covariance + device_modules_2: ModuleMapping[Module] = iot_modules # noqa: F841 + device_modules_3: ModuleMapping[Module] = smart_modules # noqa: F841 + NEW_MODULE: ModuleName[Module] = NEW_SMART_MODULE # noqa: F841 + NEW_MODULE_2: ModuleName[Module] = NEW_IOT_MODULE # noqa: F841 diff --git a/kasa/protocol.py b/kasa/protocol.py deleted file mode 100755 index 6ee6f72d6..000000000 --- a/kasa/protocol.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Implementation of the TP-Link Smart Home Protocol. - -Encryption/Decryption methods based on the works of -Lubomir Stroetmann and Tobias Esser - -https://www.softscheck.com/en/reverse-engineering-tp-link-hs110/ -https://github.com/softScheck/tplink-smartplug/ - -which are licensed under the Apache License, Version 2.0 -http://www.apache.org/licenses/LICENSE-2.0 -""" -import asyncio -import json -import logging -import struct -from pprint import pformat as pf -from typing import Dict, Union - -from .exceptions import SmartDeviceException - -_LOGGER = logging.getLogger(__name__) - - -class TPLinkSmartHomeProtocol: - """Implementation of the TP-Link Smart Home protocol.""" - - INITIALIZATION_VECTOR = 171 - DEFAULT_PORT = 9999 - DEFAULT_TIMEOUT = 5 - - @staticmethod - async def query(host: str, request: Union[str, Dict], retry_count: int = 3) -> Dict: - """Request information from a TP-Link SmartHome Device. - - :param str host: host name or ip address of the device - :param request: command to send to the device (can be either dict or - json string) - :param retry_count: how many retries to do in case of failure - :return: response dict - """ - if isinstance(request, dict): - request = json.dumps(request) - - timeout = TPLinkSmartHomeProtocol.DEFAULT_TIMEOUT - writer = None - for retry in range(retry_count + 1): - try: - task = asyncio.open_connection( - host, TPLinkSmartHomeProtocol.DEFAULT_PORT - ) - reader, writer = await asyncio.wait_for(task, timeout=timeout) - _LOGGER.debug("> (%i) %s", len(request), request) - writer.write(TPLinkSmartHomeProtocol.encrypt(request)) - await writer.drain() - - buffer = bytes() - # Some devices send responses with a length header of 0 and - # terminate with a zero size chunk. Others send the length and - # will hang if we attempt to read more data. - length = -1 - while True: - chunk = await reader.read(4096) - if length == -1: - length = struct.unpack(">I", chunk[0:4])[0] - buffer += chunk - if (length > 0 and len(buffer) >= length + 4) or not chunk: - break - - response = TPLinkSmartHomeProtocol.decrypt(buffer[4:]) - json_payload = json.loads(response) - _LOGGER.debug("< (%i) %s", len(response), pf(json_payload)) - - return json_payload - - except Exception as ex: - if retry >= retry_count: - _LOGGER.debug("Giving up after %s retries", retry) - raise SmartDeviceException( - "Unable to query the device: %s" % ex - ) from ex - - _LOGGER.debug("Unable to query the device, retrying: %s", ex) - - finally: - if writer: - writer.close() - await writer.wait_closed() - - # make mypy happy, this should never be reached.. - raise SmartDeviceException("Query reached somehow to unreachable") - - @staticmethod - def encrypt(request: str) -> bytes: - """Encrypt a request for a TP-Link Smart Home Device. - - :param request: plaintext request data - :return: ciphertext to be send over wire, in bytes - """ - key = TPLinkSmartHomeProtocol.INITIALIZATION_VECTOR - - plainbytes = request.encode() - buffer = bytearray(struct.pack(">I", len(plainbytes))) - - for plainbyte in plainbytes: - cipherbyte = key ^ plainbyte - key = cipherbyte - buffer.append(cipherbyte) - - return bytes(buffer) - - @staticmethod - def decrypt(ciphertext: bytes) -> str: - """Decrypt a response of a TP-Link Smart Home Device. - - :param ciphertext: encrypted response data - :return: plaintext response - """ - key = TPLinkSmartHomeProtocol.INITIALIZATION_VECTOR - buffer = [] - - for cipherbyte in ciphertext: - plainbyte = key ^ cipherbyte - key = cipherbyte - buffer.append(plainbyte) - - plaintext = bytes(buffer) - - return plaintext.decode() diff --git a/kasa/protocols/__init__.py b/kasa/protocols/__init__.py new file mode 100644 index 000000000..b994d7324 --- /dev/null +++ b/kasa/protocols/__init__.py @@ -0,0 +1,14 @@ +"""Package containing all supported protocols.""" + +from .iotprotocol import IotProtocol +from .protocol import BaseProtocol +from .smartcamprotocol import SmartCamProtocol +from .smartprotocol import SmartErrorCode, SmartProtocol + +__all__ = [ + "BaseProtocol", + "IotProtocol", + "SmartErrorCode", + "SmartProtocol", + "SmartCamProtocol", +] diff --git a/kasa/protocols/iotprotocol.py b/kasa/protocols/iotprotocol.py new file mode 100755 index 000000000..8d85733e2 --- /dev/null +++ b/kasa/protocols/iotprotocol.py @@ -0,0 +1,204 @@ +"""Module for the IOT legacy IOT KASA protocol.""" + +from __future__ import annotations + +import asyncio +import logging +import re +from collections.abc import Callable +from pprint import pformat as pf +from typing import TYPE_CHECKING, Any + +from ..deviceconfig import DeviceConfig +from ..exceptions import ( + AuthenticationError, + KasaException, + TimeoutError, + _ConnectionError, + _RetryableError, +) +from ..json import dumps as json_dumps +from ..transports import XorEncryption, XorTransport +from .protocol import BaseProtocol, mask_mac, redact_data + +if TYPE_CHECKING: + from ..transports import BaseTransport + +_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::], + "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 +} + + +class IotProtocol(BaseProtocol): + """Class for the legacy TPLink IOT KASA Protocol.""" + + BACKOFF_SECONDS_AFTER_TIMEOUT = 1 + + def __init__( + self, + *, + transport: BaseTransport, + ) -> None: + """Create a protocol object.""" + super().__init__(transport=transport) + + self._query_lock = asyncio.Lock() + self._redact_data = True + + async def query(self, request: str | dict, retry_count: int = 3) -> dict: + """Query the device retrying for retry_count on failure.""" + if isinstance(request, dict): + request = json_dumps(request) + assert isinstance(request, str) # noqa: S101 + + async with self._query_lock: + return await self._query(request, retry_count) + + async def _query(self, request: str, retry_count: int = 3) -> dict: + for retry in range(retry_count + 1): + try: + return await self._execute_query(request, retry) + except _ConnectionError as sdex: + if retry >= retry_count: + _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) + raise sdex + continue + except AuthenticationError as auex: + await self._transport.reset() + _LOGGER.debug( + "Unable to authenticate with %s, not retrying", self._host + ) + 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) + raise ex + await asyncio.sleep(self.BACKOFF_SECONDS_AFTER_TIMEOUT) + continue + except KasaException as ex: + await self._transport.reset() + _LOGGER.debug( + "Unable to query the device: %s, not retrying: %s", + self._host, + ex, + ) + raise ex + + # make mypy happy, this should never be reached.. + raise KasaException("Query reached somehow to unreachable") + + async def _execute_query(self, request: str, retry_count: int) -> dict: + debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) + + if debug_enabled: + _LOGGER.debug( + "%s >> %s", + self._host, + request, + ) + resp = await self._transport.send(request) + + if debug_enabled: + data = redact_data(resp, REDACTORS) if self._redact_data else resp + _LOGGER.debug( + "%s << %s", + self._host, + pf(data), + ) + return resp + + async def close(self) -> None: + """Close the underlying transport.""" + await self._transport.close() + + +class _deprecated_TPLinkSmartHomeProtocol(IotProtocol): + def __init__( + self, + host: str | None = None, + *, + port: int | None = None, + timeout: int | None = None, + transport: BaseTransport | None = None, + ) -> None: + """Create a protocol object.""" + if not host and not transport: + raise KasaException("host or transport must be supplied") + if not transport: + config = DeviceConfig( + host=host, # type: ignore[arg-type] + port_override=port, + timeout=timeout or XorTransport.DEFAULT_TIMEOUT, + ) + transport = XorTransport(config=config) + super().__init__(transport=transport) + + @staticmethod + def encrypt(request: str) -> bytes: + """Encrypt a request for a TP-Link Smart Home Device. + + :param request: plaintext request data + :return: ciphertext to be send over wire, in bytes + """ + return XorEncryption.encrypt(request) + + @staticmethod + def decrypt(ciphertext: bytes) -> str: + """Decrypt a response of a TP-Link Smart Home Device. + + :param ciphertext: encrypted response data + :return: plaintext response + """ + return XorEncryption.decrypt(ciphertext) diff --git a/kasa/protocols/protocol.py b/kasa/protocols/protocol.py new file mode 100755 index 000000000..fb09b8828 --- /dev/null +++ b/kasa/protocols/protocol.py @@ -0,0 +1,107 @@ +"""Implementation of the TP-Link Smart Home Protocol. + +Encryption/Decryption methods based on the works of +Lubomir Stroetmann and Tobias Esser + +https://www.softscheck.com/en/reverse-engineering-tp-link-hs110/ +https://github.com/softScheck/tplink-smartplug/ + +which are licensed under the Apache License, Version 2.0 +http://www.apache.org/licenses/LICENSE-2.0 +""" + +from __future__ import annotations + +import errno +import hashlib +import logging +import struct +from abc import ABC, abstractmethod +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, TypeVar, cast + +from ..deviceconfig import DeviceConfig + +_LOGGER = logging.getLogger(__name__) +_NO_RETRY_ERRORS = {errno.EHOSTDOWN, errno.EHOSTUNREACH, errno.ECONNREFUSED} +_UNSIGNED_INT_NETWORK_ORDER = struct.Struct(">I") + +_T = TypeVar("_T") + + +if TYPE_CHECKING: + from ..transports import BaseTransport + + +def redact_data(data: _T, redactors: dict[str, Callable[[Any], Any] | None]) -> _T: + """Redact sensitive data for logging.""" + if not isinstance(data, dict | list): + return data + + if isinstance(data, list): + return cast(_T, [redact_data(val, redactors) for val in data]) + + redacted = {**data} + + for key, value in redacted.items(): + if value is None: + continue + if isinstance(value, str) and not value: + continue + if key in redactors: + if redactor := redactors[key]: + try: + redacted[key] = redactor(value) + except: # noqa: E722 + redacted[key] = "**REDACTEX**" + else: + redacted[key] = "**REDACTED**" + elif isinstance(value, dict): + redacted[key] = redact_data(value, redactors) + elif isinstance(value, list): + redacted[key] = [redact_data(item, redactors) for item in value] + + return cast(_T, redacted) + + +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}" + + +def md5(payload: bytes) -> bytes: + """Return the MD5 hash of the payload.""" + return hashlib.md5(payload).digest() # noqa: S324 + + +class BaseProtocol(ABC): + """Base class for all TP-Link Smart Home communication.""" + + def __init__( + self, + *, + transport: BaseTransport, + ) -> None: + """Create a protocol object.""" + self._transport = transport + + @property + def _host(self) -> str: + return self._transport._host + + @property + def config(self) -> DeviceConfig: + """Return the connection parameters the device is using.""" + return self._transport._config + + @abstractmethod + async def query(self, request: str | dict, retry_count: int = 3) -> dict: + """Query the device for the protocol. Abstract method to be overriden.""" + + @abstractmethod + async def close(self) -> None: + """Close the protocol. Abstract method to be overriden.""" diff --git a/kasa/protocols/smartcamprotocol.py b/kasa/protocols/smartcamprotocol.py new file mode 100644 index 000000000..9bf40f7d1 --- /dev/null +++ b/kasa/protocols/smartcamprotocol.py @@ -0,0 +1,262 @@ +"""Module for SmartCamProtocol.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from pprint import pformat as pf +from typing import Any, cast + +from ..exceptions import ( + AuthenticationError, + DeviceError, + KasaException, + _RetryableError, +) +from ..json import dumps as json_dumps +from ..transports.sslaestransport import ( + SMART_AUTHENTICATION_ERRORS, + SMART_RETRYABLE_ERRORS, + SmartErrorCode, +) +from .smartprotocol import SmartProtocol + +_LOGGER = logging.getLogger(__name__) + +# List of getMethodNames that should be sent as {"method":"do"} +# https://md.depau.eu/s/r1Ys_oWoP#Modules +GET_METHODS_AS_DO = { + "getSdCardFormatStatus", + "getConnectionType", + "getUserID", + "getP2PSharePassword", + "getAESEncryptKey", + "getFirmwareAFResult", + "getWhitelampStatus", +} + + +@dataclass +class SingleRequest: + """Class for returning single request details from helper functions.""" + + method_type: str + method_name: str + param_name: str + request: dict[str, Any] + + +class SmartCamProtocol(SmartProtocol): + """Class for SmartCam Protocol.""" + + 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 + ) -> None: + error_code_raw = resp_dict.get("error_code") + 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 + + if error_code is SmartErrorCode.SUCCESS: + return + + if not raise_on_error: + resp_dict["result"] = error_code + return + + msg = ( + f"Error querying device: {self._host}: " + + f"{error_code.name}({error_code.value})" + + f" for method: {method}" + ) + if error_code in SMART_RETRYABLE_ERRORS: + raise _RetryableError(msg, error_code=error_code) + if error_code in SMART_AUTHENTICATION_ERRORS: + raise AuthenticationError(msg, error_code=error_code) + raise DeviceError(msg, error_code=error_code) + + async def close(self) -> None: + """Close the underlying transport.""" + await self._transport.close() + + @staticmethod + def _get_smart_camera_single_request( + request: dict[str, dict[str, Any]], + ) -> SingleRequest: + method = next(iter(request)) + if method == "multipleRequest": + method_type = "multi" + params = request["multipleRequest"] + req = {"method": "multipleRequest", "params": params} + return SingleRequest("multi", "multipleRequest", "", req) + + param = next(iter(request[method])) + method_type = method + req = { + "method": method, + param: request[method][param], + } + return SingleRequest(method_type, method, param, req) + + @staticmethod + def _make_snake_name(name: str) -> str: + """Convert camel or pascal case to snake name.""" + sn = "".join(["_" + i.lower() if i.isupper() else i for i in name]).lstrip("_") + return sn + + @staticmethod + def _make_smart_camera_single_request( + request: str, + ) -> SingleRequest: + """Make a single request given a method name and no params. + + If method like getSomeThing then module will be some_thing. + """ + method = request + method_type = request[:3] + snake_name = SmartCamProtocol._make_snake_name(request) + param = snake_name[4:] + if ( + (short_method := method[:3]) + and short_method in {"get", "set"} + and method not in GET_METHODS_AS_DO + ): + method_type = short_method + param = snake_name[4:] + else: + method_type = "do" + param = snake_name + req = {"method": method_type, param: {}} + return SingleRequest(method_type, method, param, req) + + async def _execute_query( + self, request: str | dict, *, retry_count: int, iterate_list_pages: bool = True + ) -> dict: + debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) + if isinstance(request, dict): + method = next(iter(request)) + 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, iterate_list_pages + ) + else: + single_request = self._make_smart_camera_single_request(request) + + smart_request = json_dumps(single_request.request) + if debug_enabled: + _LOGGER.debug( + "%s >> %s", + self._host, + pf(smart_request), + ) + response_data = await self._transport.send(smart_request) + + if debug_enabled: + _LOGGER.debug( + "%s << %s", + self._host, + pf(response_data), + ) + + if "error_code" in response_data: + # H200 does not return an error code + self._handle_response_error_code(response_data, single_request.method_name) + # Requests that are invalid and raise PROTOCOL_FORMAT_ERROR when sent + # as a multipleRequest will return {} when sent as a single request. + if single_request.method_type == "get" and ( + not (section := next(iter(response_data))) or response_data[section] == {} + ): + raise DeviceError( + f"No results for get request {single_request.method_name}" + ) + + # TODO need to update handle response lists + + if single_request.method_type == "do": + return {single_request.method_name: response_data} + if single_request.method_type == "set": + return {} + if single_request.method_type == "multi": + return {single_request.method_name: response_data["result"]} + return { + single_request.method_name: { + single_request.param_name: response_data[single_request.param_name] + } + } + + +class _ChildCameraProtocolWrapper(SmartProtocol): + """Protocol wrapper for controlling child devices. + + This is an internal class used to communicate with child devices, + and should not be used directly. + + This class overrides query() method of the protocol to modify all + outgoing queries to use ``controlChild`` command, and unwraps the + device responses before returning to the caller. + """ + + def __init__(self, device_id: str, base_protocol: SmartProtocol) -> None: + self._device_id = device_id + self._protocol = base_protocol + self._transport = base_protocol._transport + + async def query(self, request: str | dict, retry_count: int = 3) -> dict: + """Wrap request inside controlChild envelope.""" + return await self._query(request, retry_count) + + async def _query(self, request: str | dict, retry_count: int = 3) -> dict: + """Wrap request inside controlChild envelope.""" + if not isinstance(request, dict): + raise KasaException("Child requests must be dictionaries.") + requests = [] + methods = [] + for key, val in request.items(): + request = { + "method": "controlChild", + "params": { + "childControl": { + "device_id": self._device_id, + "request_data": {"method": key, "params": val}, + } + }, + } + methods.append(key) + requests.append(request) + + multipleRequest = {"multipleRequest": {"requests": requests}} + + response = await self._protocol.query(multipleRequest, retry_count) + + 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=raise_on_error + ) + response_dict[method] = response_data.get("result") + + return response_dict + + async def close(self) -> None: + """Do nothing as the parent owns the protocol.""" diff --git a/kasa/protocols/smartprotocol.py b/kasa/protocols/smartprotocol.py new file mode 100644 index 000000000..ad3e7331e --- /dev/null +++ b/kasa/protocols/smartprotocol.py @@ -0,0 +1,522 @@ +"""Implementation of the TP-Link AES Protocol. + +Based on the work of https://github.com/petretiandrea/plugp100 +under compatible GNU GPL3 license. +""" + +from __future__ import annotations + +import asyncio +import base64 +import logging +import re +import time +import uuid +from collections.abc import Callable +from pprint import pformat as pf +from typing import TYPE_CHECKING, Any + +from ..exceptions import ( + SMART_AUTHENTICATION_ERRORS, + SMART_RETRYABLE_ERRORS, + AuthenticationError, + DeviceError, + KasaException, + SmartErrorCode, + TimeoutError, + _ConnectionError, + _RetryableError, +) +from ..json import dumps as json_dumps +from .protocol import BaseProtocol, mask_mac, md5, redact_data + +if TYPE_CHECKING: + from ..transports import BaseTransport + + +_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, + "la": lambda x: 0, # lat on ks240 + "lo": lambda x: 0, # lon on ks240 + "device_id": lambda x: "REDACTED_" + x[9::], + "parent_device_id": lambda x: "REDACTED_" + x[9::], # Hub attached children + "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 "", + "bssid": lambda _: "000000000000", + "channel": lambda _: 0, + "oem_id": lambda x: "REDACTED_" + x[9::], + "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", +} + + +class SmartProtocol(BaseProtocol): + """Class for the new TPLink SMART protocol.""" + + BACKOFF_SECONDS_AFTER_TIMEOUT = 1 + DEFAULT_MULTI_REQUEST_BATCH_SIZE = 5 + + def __init__( + self, + *, + transport: BaseTransport, + ) -> None: + """Create a protocol object.""" + super().__init__(transport=transport) + self._terminal_uuid: str = base64.b64encode(md5(uuid.uuid4().bytes)).decode() + self._query_lock = asyncio.Lock() + self._multi_request_batch_size = ( + 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.""" + request = { + "method": method, + "request_time_milis": round(time.time() * 1000), + "terminal_uuid": self._terminal_uuid, + } + if params: + request["params"] = params + return json_dumps(request) + + async def query(self, request: str | dict, retry_count: int = 3) -> dict: + """Query the device retrying for retry_count on failure.""" + async with self._query_lock: + return await self._query(request, retry_count) + + async def _query(self, request: str | dict, retry_count: int = 3) -> dict: + for retry in range(retry_count + 1): + try: + return await self._execute_query( + request, retry_count=retry, iterate_list_pages=True + ) + except _ConnectionError as ex: + if retry == 0: + _LOGGER.debug( + "Device %s got a connection error, will retry %s times: %s", + self._host, + retry_count, + ex, + ) + if retry >= retry_count: + _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) + raise ex + continue + except AuthenticationError as ex: + await self._transport.reset() + _LOGGER.debug( + "Unable to authenticate with %s, not retrying: %s", self._host, ex + ) + raise ex + 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 + await asyncio.sleep(self.BACKOFF_SECONDS_AFTER_TIMEOUT) + 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) + raise ex + await asyncio.sleep(self.BACKOFF_SECONDS_AFTER_TIMEOUT) + continue + except KasaException as ex: + await self._transport.reset() + _LOGGER.debug( + "Unable to query the device: %s, not retrying: %s", + self._host, + ex, + ) + raise ex + + # 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, iterate_list_pages: bool + ) -> dict: + debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) + multi_result: dict[str, Any] = {} + smart_method = "multipleRequest" + + 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: + # If step is 1 do not send request batches + for request in multi_requests: + method = request["method"] + req = self.get_smart_request(method, request.get("params")) + resp = await self._transport.send(req) + self._handle_response_error_code( + resp, method, raise_on_error=raise_on_error + ) + multi_result[method] = resp["result"] + return multi_result + + for batch_num, i in enumerate(range(0, end, step)): + requests_step = multi_requests[i : i + step] + + 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}" + if debug_enabled: + _LOGGER.debug( + "%s %s >> %s", + self._host, + batch_name, + pf(smart_request), + ) + response_step = await self._transport.send(smart_request) + if debug_enabled: + if self._redact_data: + data = redact_data(response_step, REDACTORS) + else: + data = response_step + _LOGGER.debug( + "%s %s << %s", + self._host, + batch_name, + pf(data), + ) + try: + self._handle_response_error_code(response_step, batch_name) + except DeviceError as ex: + # P100 sometimes raises JSON_DECODE_FAIL_ERROR or INTERNAL_UNKNOWN_ERROR + # on batched request so disable batching + if ( + ex.error_code + in { + SmartErrorCode.JSON_DECODE_FAIL_ERROR, + SmartErrorCode.INTERNAL_UNKNOWN_ERROR, + } + and self._multi_request_batch_size != 1 + ): + self._multi_request_batch_size = 1 + raise _RetryableError( + "JSON Decode failure, multi requests disabled" + ) from ex + raise ex + + responses = response_step["result"]["responses"] + for response in responses: + # 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) + 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. + # 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=raise_on_error + ) + multi_result[method] = resp.get("result") + return multi_result + + async def _execute_query( + self, request: str | dict, *, retry_count: int, iterate_list_pages: bool = True + ) -> dict: + debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) + + if isinstance(request, dict): + if len(request) == 1: + smart_method = next(iter(request)) + smart_params = request[smart_method] + else: + return await self._execute_multiple_query( + request, retry_count, iterate_list_pages + ) + else: + smart_method = request + smart_params = None + + smart_request = self.get_smart_request(smart_method, smart_params) + if debug_enabled: + _LOGGER.debug( + "%s >> %s", + self._host, + pf(smart_request), + ) + response_data = await self._transport.send(smart_request) + + if debug_enabled: + _LOGGER.debug( + "%s << %s", + self._host, + pf(response_data), + ) + + self._handle_response_error_code(response_data, smart_method) + + # Single set_ requests do not return a result + result = response_data.get("result") + if iterate_list_pages and result: + await self._handle_response_lists( + 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, + params: dict | None, + retry_count: int, + ) -> None: + if ( + response_result is None + or isinstance(response_result, SmartErrorCode) + or "start_index" not in response_result + or (list_sum := response_result.get("sum")) is None + ): + return + + response_list_name = next( + iter( + [ + key + for key in response_result + if isinstance(response_result[key], list) + ] + ) + ) + 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( + request, + retry_count=retry_count, + iterate_list_pages=False, + ) + next_batch = response[method] + # In case the device returns empty lists avoid infinite looping + if not next_batch[response_list_name]: + _LOGGER.error( + "Device %s returned empty results list for method %s", + self._host, + method, + ) + break + response_result[response_list_name].extend(next_batch[response_list_name]) + + def _handle_response_error_code( + self, resp_dict: dict, method: str, raise_on_error: bool = True + ) -> None: + error_code_raw = resp_dict.get("error_code") + 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 + + if error_code is SmartErrorCode.SUCCESS: + return + + if not raise_on_error: + resp_dict["result"] = error_code + return + + msg = ( + f"Error querying device: {self._host}: " + + f"{error_code.name}({error_code.value})" + + f" for method: {method}" + ) + if error_code in SMART_RETRYABLE_ERRORS: + raise _RetryableError(msg, error_code=error_code) + if error_code in SMART_AUTHENTICATION_ERRORS: + raise AuthenticationError(msg, error_code=error_code) + raise DeviceError(msg, error_code=error_code) + + async def close(self) -> None: + """Close the underlying transport.""" + await self._transport.close() + + +class _ChildProtocolWrapper(SmartProtocol): + """Protocol wrapper for controlling child devices. + + This is an internal class used to communicate with child devices, + and should not be used directly. + + This class overrides query() method of the protocol to modify all + outgoing queries to use ``control_child`` command, and unwraps the + device responses before returning to the caller. + """ + + def __init__(self, device_id: str, base_protocol: SmartProtocol) -> None: + self._device_id = device_id + self._protocol = base_protocol + self._transport = base_protocol._transport + + def _get_method_and_params_for_request(self, request: dict[str, Any] | str) -> Any: + """Return payload for wrapping. + + TODO: this does not support batches and requires refactoring in the future. + """ + if isinstance(request, dict): + if len(request) == 1: + smart_method = next(iter(request)) + smart_params = request[smart_method] + else: + smart_method = "multipleRequest" + requests = [ + {"method": method, "params": params} + if params + else {"method": method} + for method, params in request.items() + ] + smart_params = {"requests": requests} + else: + smart_method = request + smart_params = None + + return smart_method, smart_params + + async def query(self, request: str | dict, retry_count: int = 3) -> dict: + """Wrap request inside control_child envelope.""" + return await self._query(request, retry_count) + + async def _query(self, request: str | dict, retry_count: int = 3) -> dict: + """Wrap request inside control_child envelope.""" + method, params = self._get_method_and_params_for_request(request) + request_data = { + "method": method, + "params": params, + } + wrapped_payload = { + "control_child": { + "device_id": self._device_id, + "requestData": request_data, + } + } + + response = await self._protocol.query(wrapped_payload, retry_count) + result = response.get("control_child") + # Unwrap responseData for control_child + if result and (response_data := result.get("responseData")): + result = response_data.get("result") + if result and (multi_responses := result.get("responses")): + ret_val = {} + for multi_response in multi_responses: + method = multi_response["method"] + self._handle_response_error_code( + multi_response, method, raise_on_error=False + ) + ret_val[method] = multi_response.get("result") + return ret_val + + self._handle_response_error_code(response_data, "control_child") + + return {method: result} + + async def close(self) -> None: + """Do nothing as the parent owns the protocol.""" diff --git a/kasa/tests/__init__.py b/kasa/py.typed similarity index 100% rename from kasa/tests/__init__.py rename to kasa/py.typed diff --git a/kasa/smart/__init__.py b/kasa/smart/__init__.py new file mode 100644 index 000000000..09e3aba50 --- /dev/null +++ b/kasa/smart/__init__.py @@ -0,0 +1,6 @@ +"""Package for supporting tapo-branded and newer kasa devices.""" + +from .smartchilddevice import SmartChildDevice +from .smartdevice import SmartDevice + +__all__ = ["SmartDevice", "SmartChildDevice"] diff --git a/kasa/smart/effects.py b/kasa/smart/effects.py new file mode 100644 index 000000000..815f777b7 --- /dev/null +++ b/kasa/smart/effects.py @@ -0,0 +1,456 @@ +"""Module for light strip light effects.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import cast + +from ..interfaces.lighteffect import LightEffect as LightEffectInterface + + +class SmartLightEffect(LightEffectInterface, ABC): + """Abstract interface for smart light effects. + + This interface extends lighteffect interface to add brightness controls. + """ + + @abstractmethod + async def set_brightness( + self, brightness: int, *, transition: int | None = None + ) -> dict: + """Set effect brightness.""" + + @property + @abstractmethod + def brightness(self) -> int: + """Return effect brightness.""" + + @property + @abstractmethod + def is_active(self) -> bool: + """Return True if effect is active.""" + + +EFFECT_AURORA = { + "custom": 0, + "id": "TapoStrip_1MClvV18i15Jq3bvJVf0eP", + "brightness": 100, + "name": "Aurora", + "enable": 1, + "segments": [0], + "expansion_strategy": 1, + "display_colors": [ + [120, 100, 100], + [240, 100, 100], + [260, 100, 100], + [280, 100, 100], + ], + "type": "sequence", + "duration": 0, + "transition": 1500, + "direction": 4, + "spread": 7, + "repeat_times": 0, + "sequence": [[120, 100, 100], [240, 100, 100], [260, 100, 100], [280, 100, 100]], +} +EFFECT_BUBBLING_CAULDRON = { + "custom": 0, + "id": "TapoStrip_6DlumDwO2NdfHppy50vJtu", + "brightness": 100, + "name": "Bubbling Cauldron", + "enable": 1, + "segments": [0], + "expansion_strategy": 1, + "display_colors": [[100, 100, 100], [270, 100, 100]], + "type": "random", + "hue_range": [100, 270], + "saturation_range": [80, 100], + "brightness_range": [50, 100], + "duration": 0, + "transition": 200, + "init_states": [[270, 100, 100]], + "fadeoff": 1000, + "random_seed": 24, + "backgrounds": [[270, 40, 50]], +} +EFFECT_CANDY_CANE = { + "custom": 0, + "id": "TapoStrip_6Dy0Nc45vlhFPEzG021Pe9", + "brightness": 100, + "name": "Candy Cane", + "enable": 1, + "segments": [0], + "expansion_strategy": 1, + "display_colors": [[0, 0, 100], [0, 81, 100]], + "type": "sequence", + "duration": 700, + "transition": 500, + "direction": 1, + "spread": 1, + "repeat_times": 0, + "sequence": [ + [0, 0, 100], + [0, 0, 100], + [360, 81, 100], + [0, 0, 100], + [0, 0, 100], + [360, 81, 100], + [360, 81, 100], + [0, 0, 100], + [0, 0, 100], + [360, 81, 100], + [360, 81, 100], + [360, 81, 100], + [360, 81, 100], + [0, 0, 100], + [0, 0, 100], + [360, 81, 100], + ], +} +EFFECT_CHRISTMAS = { + "custom": 0, + "id": "TapoStrip_5zkiG6avJ1IbhjiZbRlWvh", + "brightness": 100, + "name": "Christmas", + "enable": 1, + "segments": [0], + "expansion_strategy": 1, + "display_colors": [[136, 98, 100], [350, 97, 100]], + "type": "random", + "hue_range": [136, 146], + "saturation_range": [90, 100], + "brightness_range": [50, 100], + "duration": 5000, + "transition": 0, + "init_states": [[136, 0, 100]], + "fadeoff": 2000, + "random_seed": 100, + "backgrounds": [[136, 98, 75], [136, 0, 0], [350, 0, 100], [350, 97, 94]], +} +EFFECT_FLICKER = { + "custom": 0, + "id": "TapoStrip_4HVKmMc6vEzjm36jXaGwMs", + "brightness": 100, + "name": "Flicker", + "enable": 1, + "segments": [1], + "expansion_strategy": 1, + "display_colors": [[30, 81, 100], [40, 100, 100]], + "type": "random", + "hue_range": [30, 40], + "saturation_range": [100, 100], + "brightness_range": [50, 100], + "duration": 0, + "transition": 0, + "transition_range": [375, 500], + "init_states": [[30, 81, 80]], +} +EFFECT_GRANDMAS_CHRISTMAS_LIGHTS = { + "custom": 0, + "id": "TapoStrip_3Gk6CmXOXbjCiwz9iD543C", + "brightness": 100, + "name": "Grandma's Christmas Lights", + "enable": 1, + "segments": [0], + "expansion_strategy": 1, + "display_colors": [[30, 100, 100], [240, 100, 100], [130, 100, 100], [0, 100, 100]], + "type": "sequence", + "duration": 5000, + "transition": 100, + "direction": 1, + "spread": 1, + "repeat_times": 0, + "sequence": [ + [30, 100, 100], + [30, 0, 0], + [30, 0, 0], + [240, 100, 100], + [240, 0, 0], + [240, 0, 0], + [240, 0, 100], + [240, 0, 0], + [240, 0, 0], + [130, 100, 100], + [130, 0, 0], + [130, 0, 0], + [0, 100, 100], + [0, 0, 0], + [0, 0, 0], + ], +} +EFFECT_HANUKKAH = { + "custom": 0, + "id": "TapoStrip_2YTk4wramLKv5XZ9KFDVYm", + "brightness": 100, + "name": "Hanukkah", + "enable": 1, + "segments": [1], + "expansion_strategy": 1, + "display_colors": [[200, 100, 100]], + "type": "random", + "hue_range": [200, 210], + "saturation_range": [0, 100], + "brightness_range": [50, 100], + "duration": 1500, + "transition": 0, + "transition_range": [400, 500], + "init_states": [[35, 81, 80]], +} +EFFECT_HAUNTED_MANSION = { + "custom": 0, + "id": "TapoStrip_4rJ6JwC7I9st3tQ8j4lwlI", + "brightness": 100, + "name": "Haunted Mansion", + "enable": 1, + "segments": [80], + "expansion_strategy": 2, + "display_colors": [[44, 9, 100]], + "type": "random", + "hue_range": [45, 45], + "saturation_range": [10, 10], + "brightness_range": [0, 80], + "duration": 0, + "transition": 0, + "transition_range": [50, 1500], + "init_states": [[45, 10, 100]], + "fadeoff": 200, + "random_seed": 1, + "backgrounds": [[45, 10, 100]], +} +EFFECT_ICICLE = { + "custom": 0, + "id": "TapoStrip_7UcYLeJbiaxVIXCxr21tpx", + "brightness": 100, + "name": "Icicle", + "enable": 1, + "segments": [0], + "expansion_strategy": 1, + "display_colors": [[190, 100, 100]], + "type": "sequence", + "duration": 0, + "transition": 400, + "direction": 4, + "spread": 3, + "repeat_times": 0, + "sequence": [ + [190, 100, 70], + [190, 100, 70], + [190, 30, 50], + [190, 100, 70], + [190, 100, 70], + ], +} +EFFECT_LIGHTNING = { + "custom": 0, + "id": "TapoStrip_7OGzfSfnOdhoO2ri4gOHWn", + "brightness": 100, + "name": "Lightning", + "enable": 1, + "segments": [7], + "expansion_strategy": 1, + "display_colors": [[210, 9, 100], [200, 50, 100], [200, 100, 100]], + "type": "random", + "hue_range": [240, 240], + "saturation_range": [10, 11], + "brightness_range": [90, 100], + "duration": 0, + "transition": 50, + "init_states": [[240, 30, 100]], + "fadeoff": 150, + "random_seed": 50, + "backgrounds": [[200, 100, 100], [200, 50, 10], [210, 10, 50], [240, 10, 0]], +} +EFFECT_OCEAN = { + "custom": 0, + "id": "TapoStrip_0fOleCdwSgR0nfjkReeYfw", + "brightness": 100, + "name": "Ocean", + "enable": 1, + "segments": [0], + "expansion_strategy": 1, + "display_colors": [[198, 84, 100]], + "type": "sequence", + "duration": 0, + "transition": 2000, + "direction": 3, + "spread": 16, + "repeat_times": 0, + "sequence": [[198, 84, 30], [198, 70, 30], [198, 10, 30]], +} +EFFECT_RAINBOW = { + "custom": 0, + "id": "TapoStrip_7CC5y4lsL8pETYvmz7UOpQ", + "brightness": 100, + "name": "Rainbow", + "enable": 1, + "segments": [0], + "expansion_strategy": 1, + "display_colors": [ + [0, 100, 100], + [100, 100, 100], + [200, 100, 100], + [300, 100, 100], + ], + "type": "sequence", + "duration": 0, + "transition": 1500, + "direction": 1, + "spread": 12, + "repeat_times": 0, + "sequence": [[0, 100, 100], [100, 100, 100], [200, 100, 100], [300, 100, 100]], +} +EFFECT_RAINDROP = { + "custom": 0, + "id": "TapoStrip_1t2nWlTBkV8KXBZ0TWvBjs", + "brightness": 100, + "name": "Raindrop", + "enable": 1, + "segments": [0], + "expansion_strategy": 1, + "display_colors": [[200, 9, 100], [200, 19, 100]], + "type": "random", + "hue_range": [200, 200], + "saturation_range": [10, 20], + "brightness_range": [10, 30], + "duration": 0, + "transition": 1000, + "init_states": [[200, 40, 100]], + "fadeoff": 1000, + "random_seed": 24, + "backgrounds": [[200, 40, 0]], +} +EFFECT_SPRING = { + "custom": 0, + "id": "TapoStrip_1nL6GqZ5soOxj71YDJOlZL", + "brightness": 100, + "name": "Spring", + "enable": 1, + "segments": [0], + "expansion_strategy": 1, + "display_colors": [[0, 30, 100], [130, 100, 100]], + "type": "random", + "hue_range": [0, 90], + "saturation_range": [30, 100], + "brightness_range": [90, 100], + "duration": 600, + "transition": 0, + "transition_range": [2000, 6000], + "init_states": [[80, 30, 100]], + "fadeoff": 1000, + "random_seed": 20, + "backgrounds": [[130, 100, 40]], +} +EFFECT_SUNRISE = { + "custom": 0, + "id": "TapoStrip_1OVSyXIsDxrt4j7OxyRvqi", + "brightness": 100, + "name": "Sunrise", + "enable": 1, + "segments": [0], + "expansion_strategy": 2, + "display_colors": [[0, 0, 100], [30, 95, 100], [0, 100, 100]], + "type": "pulse", + "duration": 600, + "transition": 60000, + "direction": 1, + "spread": 1, + "repeat_times": 1, + "run_time": 0, + "sequence": [ + [0, 100, 5], + [0, 100, 5], + [10, 100, 6], + [15, 100, 7], + [20, 100, 8], + [20, 100, 10], + [30, 100, 12], + [30, 95, 15], + [30, 90, 20], + [30, 80, 25], + [30, 75, 30], + [30, 70, 40], + [30, 60, 50], + [30, 50, 60], + [30, 20, 70], + [30, 0, 100], + ], + "trans_sequence": [], +} +EFFECT_SUNSET = { + "custom": 0, + "id": "TapoStrip_5NiN0Y8GAUD78p4neKk9EL", + "brightness": 100, + "name": "Sunset", + "enable": 1, + "segments": [0], + "expansion_strategy": 2, + "display_colors": [[0, 100, 100], [30, 95, 100], [0, 0, 100]], + "type": "pulse", + "duration": 600, + "transition": 60000, + "direction": 1, + "spread": 1, + "repeat_times": 1, + "run_time": 0, + "sequence": [ + [30, 0, 100], + [30, 20, 100], + [30, 50, 99], + [30, 60, 98], + [30, 70, 97], + [30, 75, 95], + [30, 80, 93], + [30, 90, 90], + [30, 95, 85], + [30, 100, 80], + [20, 100, 70], + [20, 100, 60], + [15, 100, 50], + [10, 100, 40], + [0, 100, 30], + [0, 100, 0], + ], + "trans_sequence": [], +} +EFFECT_VALENTINES = { + "custom": 0, + "id": "TapoStrip_2q1Vio9sSjHmaC7JS9d30l", + "brightness": 100, + "name": "Valentines", + "enable": 1, + "segments": [0], + "expansion_strategy": 1, + "display_colors": [[339, 19, 100], [19, 50, 100], [0, 100, 100], [339, 40, 100]], + "type": "random", + "hue_range": [340, 340], + "saturation_range": [30, 40], + "brightness_range": [90, 100], + "duration": 600, + "transition": 2000, + "init_states": [[340, 30, 100]], + "fadeoff": 3000, + "random_seed": 100, + "backgrounds": [[340, 20, 50], [20, 50, 50], [0, 100, 50]], +} +EFFECTS_LIST = [ + EFFECT_AURORA, + EFFECT_BUBBLING_CAULDRON, + EFFECT_CANDY_CANE, + EFFECT_CHRISTMAS, + EFFECT_FLICKER, + EFFECT_GRANDMAS_CHRISTMAS_LIGHTS, + EFFECT_HANUKKAH, + EFFECT_HAUNTED_MANSION, + EFFECT_ICICLE, + EFFECT_LIGHTNING, + EFFECT_OCEAN, + EFFECT_RAINBOW, + EFFECT_RAINDROP, + EFFECT_SPRING, + EFFECT_SUNRISE, + EFFECT_SUNSET, + EFFECT_VALENTINES, +] + +EFFECT_NAMES: list[str] = [cast(str, effect["name"]) for effect in EFFECTS_LIST] +EFFECT_MAPPING = {effect["name"]: effect for effect in EFFECTS_LIST} diff --git a/kasa/smart/modules/__init__.py b/kasa/smart/modules/__init__.py new file mode 100644 index 000000000..154042398 --- /dev/null +++ b/kasa/smart/modules/__init__.py @@ -0,0 +1,91 @@ +"""Modules for SMART devices.""" + +from ..effects import SmartLightEffect +from .alarm import Alarm +from .autooff import AutoOff +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 +from .lighteffect import LightEffect +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 +from .time import Time +from .triggerlogs import TriggerLogs +from .waterleaksensor import WaterleakSensor + +__all__ = [ + "Alarm", + "Time", + "Energy", + "DeviceModule", + "ChildDevice", + "ChildLock", + "ChildSetup", + "BatterySensor", + "HumiditySensor", + "TemperatureSensor", + "TemperatureControl", + "ChildProtection", + "ReportMode", + "AutoOff", + "Led", + "Brightness", + "Fan", + "LightPreset", + "Firmware", + "Cloud", + "Light", + "LightEffect", + "LightStripEffect", + "LightTransition", + "ColorTemperature", + "Color", + "WaterleakSensor", + "ContactSensor", + "MotionSensor", + "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 new file mode 100644 index 000000000..cd6021829 --- /dev/null +++ b/kasa/smart/modules/alarm.py @@ -0,0 +1,262 @@ +"""Implementation of alarm module.""" + +from __future__ import annotations + +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 + +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" + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return { + "get_alarm_configure": None, + "get_support_alarm_type_list": None, # This should be needed only once + } + + def _initialize_features(self) -> None: + """Initialize features.""" + device = self._device + self._add_feature( + Feature( + device, + id="alarm", + name="Alarm", + container=self, + attribute_getter="active", + icon="mdi:bell", + type=Feature.Type.BinarySensor, + ) + ) + self._add_feature( + Feature( + device, + id="alarm_source", + name="Alarm source", + container=self, + attribute_getter="source", + icon="mdi:bell", + type=Feature.Type.Sensor, + ) + ) + self._add_feature( + Feature( + device, + id="alarm_sound", + name="Alarm sound", + container=self, + attribute_getter="alarm_sound", + attribute_setter="set_alarm_sound", + category=Feature.Category.Config, + type=Feature.Type.Choice, + choices_getter="alarm_sounds", + ) + ) + self._add_feature( + Feature( + device, + id="alarm_volume", + name="Alarm volume", + container=self, + attribute_getter="_alarm_volume_str", + attribute_setter="set_alarm_volume", + category=Feature.Category.Config, + type=Feature.Type.Choice, + 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( + Feature( + device, + id="test_alarm", + name="Test alarm", + container=self, + attribute_setter="play", + type=Feature.Type.Action, + ) + ) + self._add_feature( + Feature( + device, + id="stop_alarm", + name="Stop alarm", + container=self, + attribute_setter="stop", + type=Feature.Type.Action, + ) + ) + + @property + 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) -> 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) + + @property + def alarm_sounds(self) -> list[str]: + """Return list of available alarm sounds.""" + return self.data["get_support_alarm_type_list"]["alarm_type_list"] + + @property + 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: 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.""" + return self._device.sys_info["in_alarm"] + + @property + def source(self) -> str | None: + """Return the alarm cause.""" + src = self._device.sys_info["in_alarm_source"] + return src if src else None + + 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/autooff.py b/kasa/smart/modules/autooff.py new file mode 100644 index 000000000..4fefb0007 --- /dev/null +++ b/kasa/smart/modules/autooff.py @@ -0,0 +1,105 @@ +"""Implementation of auto off module.""" + +from __future__ import annotations + +import logging +from datetime import datetime, timedelta + +from ...feature import Feature +from ..smartmodule import SmartModule + +_LOGGER = logging.getLogger(__name__) + + +class AutoOff(SmartModule): + """Implementation of auto off module.""" + + REQUIRED_COMPONENT = "auto_off" + QUERY_GETTER_NAME = "get_auto_off_config" + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + self._device, + id="auto_off_enabled", + name="Auto off enabled", + container=self, + attribute_getter="enabled", + attribute_setter="set_enabled", + type=Feature.Type.Switch, + ) + ) + self._add_feature( + Feature( + self._device, + id="auto_off_minutes", + name="Auto off in", + container=self, + attribute_getter="delay", + attribute_setter="set_delay", + type=Feature.Type.Number, + unit_getter=lambda: "min", # ha-friendly unit, see UnitOfTime.MINUTES + ) + ) + self._add_feature( + Feature( + self._device, + id="auto_off_at", + name="Auto off at", + container=self, + attribute_getter="auto_off_at", + category=Feature.Category.Info, + type=Feature.Type.Sensor, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {self.QUERY_GETTER_NAME: {"start_index": 0}} + + @property + def enabled(self) -> bool: + """Return True if enabled.""" + return self.data["enable"] + + async def set_enabled(self, enable: bool) -> dict: + """Enable/disable auto off.""" + return await self.call( + "set_auto_off_config", + {"enable": enable, "delay_min": self.data["delay_min"]}, + ) + + @property + def delay(self) -> int: + """Return time until auto off.""" + return self.data["delay_min"] + + async def set_delay(self, delay: int) -> dict: + """Set time until auto off.""" + return await self.call( + "set_auto_off_config", {"delay_min": delay, "enable": self.data["enable"]} + ) + + @property + def is_timer_active(self) -> bool: + """Return True is auto-off timer is active.""" + return self._device.sys_info["auto_off_status"] == "on" + + @property + def auto_off_at(self) -> datetime | None: + """Return when the device will be turned off automatically.""" + if not self.is_timer_active: + return None + + sysinfo = self._device.sys_info + + return self._device.time + timedelta(seconds=sysinfo["auto_off_remain_time"]) + + async def _check_supported(self) -> bool: + """Additional check to see if the module is supported by the device. + + Parent devices that report components of children such as P300 will not have + the auto_off_status is sysinfo. + """ + return "auto_off_status" in self._device.sys_info diff --git a/kasa/smart/modules/batterysensor.py b/kasa/smart/modules/batterysensor.py new file mode 100644 index 000000000..aef100fc5 --- /dev/null +++ b/kasa/smart/modules/batterysensor.py @@ -0,0 +1,72 @@ +"""Implementation of battery module.""" + +from __future__ import annotations + +from typing import Annotated + +from ...exceptions import KasaException +from ...feature import Feature +from ...module import FeatureAttribute +from ..smartmodule import SmartModule + + +class BatterySensor(SmartModule): + """Implementation of battery module.""" + + REQUIRED_COMPONENT = "battery_detect" + QUERY_GETTER_NAME = "get_battery_detect_info" + + def _initialize_features(self) -> None: + """Initialize features.""" + 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: + self._add_feature( + Feature( + self._device, + "battery_level", + "Battery level", + container=self, + attribute_getter="battery", + icon="mdi:battery", + unit_getter=lambda: "%", + category=Feature.Category.Info, + type=Feature.Type.Sensor, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + + @property + def battery(self) -> Annotated[int, FeatureAttribute()]: + """Return battery level.""" + return self._device.sys_info["battery_percentage"] + + @property + def battery_low(self) -> Annotated[bool, FeatureAttribute()]: + """Return True if battery is low.""" + 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/brightness.py b/kasa/smart/modules/brightness.py new file mode 100644 index 000000000..b5b8d3541 --- /dev/null +++ b/kasa/smart/modules/brightness.py @@ -0,0 +1,80 @@ +"""Implementation of brightness module.""" + +from __future__ import annotations + +from ...feature import Feature +from ..smartmodule import Module, SmartModule + +BRIGHTNESS_MIN = 0 +BRIGHTNESS_MAX = 100 + + +class Brightness(SmartModule): + """Implementation of brightness module.""" + + REQUIRED_COMPONENT = "brightness" + + def _initialize_features(self) -> None: + """Initialize features.""" + super()._initialize_features() + + device = self._device + self._add_feature( + Feature( + device, + id="brightness", + name="Brightness", + container=self, + attribute_getter="brightness", + attribute_setter="set_brightness", + range_getter=lambda: (BRIGHTNESS_MIN, BRIGHTNESS_MAX), + type=Feature.Type.Number, + category=Feature.Category.Primary, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + # Brightness is contained in the main device info response. + return {} + + @property + def brightness(self) -> int: + """Return current brightness.""" + # If the device supports effects and one is active, use its brightness + if ( + light_effect := self._device.modules.get(Module.SmartLightEffect) + ) is not None and light_effect.is_active: + return light_effect.brightness + + return self.data["brightness"] + + async def set_brightness( + self, brightness: int, *, transition: int | None = None + ) -> dict: + """Set the brightness. A brightness value of 0 will turn off the light. + + Note, transition is not supported and will be ignored. + """ + if not isinstance(brightness, int) or not ( + BRIGHTNESS_MIN <= brightness <= BRIGHTNESS_MAX + ): + raise ValueError( + f"Invalid brightness value: {brightness} " + f"(valid range: {BRIGHTNESS_MIN}-{BRIGHTNESS_MAX}%)" + ) + + if brightness == 0: + return await self._device.turn_off() + + # If the device supports effects and one is active, we adjust its brightness + if ( + light_effect := self._device.modules.get(Module.SmartLightEffect) + ) is not None and light_effect.is_active: + return await light_effect.set_brightness(brightness) + + return await self.call("set_device_info", {"brightness": brightness}) + + async def _check_supported(self) -> bool: + """Additional check to see if the module is supported by the device.""" + return "brightness" in self.data diff --git a/kasa/smart/modules/childdevice.py b/kasa/smart/modules/childdevice.py new file mode 100644 index 000000000..e816e3f1c --- /dev/null +++ b/kasa/smart/modules/childdevice.py @@ -0,0 +1,56 @@ +"""Interact with child devices. + +>>> from kasa import Discover +>>> +>>> dev = await Discover.discover_single( +>>> "127.0.0.1", +>>> username="user@example.com", +>>> password="great_password" +>>> ) +>>> await dev.update() +>>> print(dev.alias) +Bedroom Power Strip + +All methods act on the whole strip: + +>>> for plug in dev.children: +>>> print(f"{plug.alias}: {plug.is_on}") +Plug 1: True +Plug 2: False +Plug 3: False +>>> dev.is_on +True +>>> await dev.turn_off() +>>> await dev.update() + +Accessing individual plugs can be done using the `children` property: + +>>> len(dev.children) +3 +>>> for plug in dev.children: +>>> print(f"{plug.alias}: {plug.is_on}") +Plug 1: False +Plug 2: False +Plug 3: False +>>> await dev.children[1].turn_on() +>>> await dev.update() +>>> dev.is_on +True +""" + +from ...device_type import DeviceType +from ..smartmodule import SmartModule + + +class ChildDevice(SmartModule): + """Implementation for child devices.""" + + 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/childprotection.py b/kasa/smart/modules/childprotection.py new file mode 100644 index 000000000..fba89cc09 --- /dev/null +++ b/kasa/smart/modules/childprotection.py @@ -0,0 +1,41 @@ +"""Child lock module.""" + +from __future__ import annotations + +from ...feature import Feature +from ..smartmodule import SmartModule + + +class ChildProtection(SmartModule): + """Implementation for child_protection.""" + + REQUIRED_COMPONENT = "child_protection" + QUERY_GETTER_NAME = "get_child_protection" + + 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, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + + @property + def enabled(self) -> bool: + """Return True if child protection is enabled.""" + return self.data["child_protection"] + + async def set_enabled(self, enabled: bool) -> dict: + """Set child protection.""" + return await self.call("set_child_protection", {"enable": 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..376e0d398 --- /dev/null +++ b/kasa/smart/modules/clean.py @@ -0,0 +1,411 @@ +"""Implementation of vacuum clean module.""" + +from __future__ import annotations + +import logging +from datetime import timedelta +from enum import IntEnum +from typing import Annotated, Literal + +from ...feature import Feature +from ...module import FeatureAttribute +from ..smartmodule import SmartModule + +_LOGGER = logging.getLogger(__name__) + + +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 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"}, + } + + 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": 0, + "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) 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/cloud.py b/kasa/smart/modules/cloud.py new file mode 100644 index 000000000..fd6d0a0f0 --- /dev/null +++ b/kasa/smart/modules/cloud.py @@ -0,0 +1,36 @@ +"""Implementation of cloud module.""" + +from __future__ import annotations + +from ...feature import Feature +from ..smartmodule import SmartModule + + +class Cloud(SmartModule): + """Implementation of cloud module.""" + + QUERY_GETTER_NAME = "get_connect_cloud_state" + REQUIRED_COMPONENT = "cloud_connect" + MINIMUM_UPDATE_INTERVAL_SECS = 60 + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + self._device, + id="cloud_connection", + name="Cloud connection", + container=self, + attribute_getter="is_connected", + icon="mdi:cloud", + type=Feature.Type.BinarySensor, + category=Feature.Category.Info, + ) + ) + + @property + def is_connected(self) -> bool: + """Return True if device is connected to the cloud.""" + if self._has_data_error(): + return False + return self.data["status"] == 0 diff --git a/kasa/smart/modules/color.py b/kasa/smart/modules/color.py new file mode 100644 index 000000000..de0c3f747 --- /dev/null +++ b/kasa/smart/modules/color.py @@ -0,0 +1,99 @@ +"""Implementation of color module.""" + +from __future__ import annotations + +from ...feature import Feature +from ...interfaces.light import HSV +from ..smartmodule import SmartModule + + +class Color(SmartModule): + """Implementation of color module.""" + + REQUIRED_COMPONENT = "color" + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + self._device, + "hsv", + "HSV", + container=self, + attribute_getter="hsv", + attribute_setter="set_hsv", + # TODO proper type for setting hsv + type=Feature.Type.Unknown, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + # HSV is contained in the main device info response. + return {} + + @property + def hsv(self) -> HSV: + """Return the current HSV state of the bulb. + + :return: hue, saturation and value (degrees, %, 1-100) + """ + h, s, v = ( + self.data.get("hue", 0), + self.data.get("saturation", 0), + self.data.get("brightness", 0), + ) + + # Simple HSV(h, s, v) is less efficent than below + # due to the cpython implementation. + return tuple.__new__(HSV, (h, s, v)) + + def _raise_for_invalid_brightness(self, value: int) -> None: + """Raise error on invalid brightness value.""" + if not isinstance(value, int): + raise TypeError("Brightness must be an integer") + if not (0 <= value <= 100): + raise ValueError(f"Invalid brightness value: {value} (valid range: 0-100%)") + + async def set_hsv( + self, + hue: int, + saturation: int, + value: int | None = None, + *, + transition: int | None = None, + ) -> dict: + """Set new HSV. + + Note, transition is not supported and will be ignored. + + :param int hue: hue in degrees + :param int saturation: saturation in percentage [0,100] + :param int value: value in percentage [0, 100] + :param int transition: transition in milliseconds. + """ + if not isinstance(hue, int): + raise TypeError("Hue must be an integer") + if not (0 <= hue <= 360): + raise ValueError(f"Invalid hue value: {hue} (valid range: 0-360)") + + if not isinstance(saturation, int): + raise TypeError("Saturation must be an integer") + if not (0 <= saturation <= 100): + raise ValueError( + f"Invalid saturation value: {saturation} (valid range: 0-100%)" + ) + + if value is not None: + self._raise_for_invalid_brightness(value) + + request_payload = { + "color_temp": 0, # If set, color_temp takes precedence over hue&sat + "hue": hue, + "saturation": saturation, + } + # The device errors on invalid brightness values. + if value is not None: + request_payload["brightness"] = value + + return await self.call("set_device_info", {**request_payload}) diff --git a/kasa/smart/modules/colortemperature.py b/kasa/smart/modules/colortemperature.py new file mode 100644 index 000000000..32d6e67da --- /dev/null +++ b/kasa/smart/modules/colortemperature.py @@ -0,0 +1,75 @@ +"""Implementation of color temp module.""" + +from __future__ import annotations + +import logging + +from ...feature import Feature +from ...interfaces.light import ColorTempRange +from ..smartmodule import SmartModule + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_TEMP_RANGE = [2500, 6500] + + +class ColorTemperature(SmartModule): + """Implementation of color temp module.""" + + REQUIRED_COMPONENT = "color_temperature" + + def _initialize_features(self) -> None: + """Initialize features.""" + self._add_feature( + Feature( + self._device, + "color_temperature", + "Color temperature", + container=self, + attribute_getter="color_temp", + attribute_setter="set_color_temp", + range_getter="valid_temperature_range", + category=Feature.Category.Primary, + type=Feature.Type.Number, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + # Color temp is contained in the main device info response. + return {} + + @property + def valid_temperature_range(self) -> ColorTempRange: + """Return valid color-temp range.""" + if (ct_range := self.data.get("color_temp_range")) is None: + _LOGGER.debug( + "Device doesn't report color temperature range, " + "falling back to default %s", + DEFAULT_TEMP_RANGE, + ) + ct_range = DEFAULT_TEMP_RANGE + return ColorTempRange(*ct_range) + + @property + def color_temp(self) -> int: + """Return current color temperature.""" + return self.data["color_temp"] + + async def set_color_temp(self, temp: int, *, brightness: int | None = None) -> dict: + """Set the color temperature.""" + valid_temperature_range = self.valid_temperature_range + if temp < valid_temperature_range[0] or temp > valid_temperature_range[1]: + raise ValueError( + "Temperature should be between {} and {}, was {}".format( + *valid_temperature_range, temp + ) + ) + params = {"color_temp": temp} + if brightness: + params["brightness"] = brightness + return await self.call("set_device_info", params) + + async def _check_supported(self) -> bool: + """Check the color_temp_range has more than one value.""" + return self.valid_temperature_range.min != self.valid_temperature_range.max 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 new file mode 100644 index 000000000..d0bebb077 --- /dev/null +++ b/kasa/smart/modules/contactsensor.py @@ -0,0 +1,37 @@ +"""Implementation of contact sensor module.""" + +from __future__ import annotations + +from ...feature import Feature +from ..smartmodule import SmartModule + + +class ContactSensor(SmartModule): + """Implementation of contact sensor module.""" + + REQUIRED_COMPONENT = None # we depend on availability of key + SYSINFO_LOOKUP_KEYS = ["open"] + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + self._device, + id="is_open", + name="Open", + container=self, + attribute_getter="is_open", + icon="mdi:door", + category=Feature.Category.Primary, + type=Feature.Type.BinarySensor, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + + @property + def is_open(self) -> bool: + """Return True if the contact sensor is open.""" + return self._device.sys_info["open"] diff --git a/kasa/smart/modules/devicemodule.py b/kasa/smart/modules/devicemodule.py new file mode 100644 index 000000000..692745bb4 --- /dev/null +++ b/kasa/smart/modules/devicemodule.py @@ -0,0 +1,33 @@ +"""Implementation of device module.""" + +from __future__ import annotations + +from ..smartmodule import SmartModule + + +class DeviceModule(SmartModule): + """Implementation of device module.""" + + REQUIRED_COMPONENT = "device" + + async def _post_update_hook(self) -> None: + """Perform actions after a device update. + + Overrides the default behaviour to disable a module if the query returns + an error because this module is critical. + """ + + 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: + 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 new file mode 100644 index 000000000..03df6d11c --- /dev/null +++ b/kasa/smart/modules/energy.py @@ -0,0 +1,167 @@ +"""Implementation of energy monitoring module.""" + +from __future__ import annotations + +from typing import Any, NoReturn + +from ...emeterstatus import EmeterStatus +from ...exceptions import DeviceError, KasaException +from ...interfaces.energy import Energy as EnergyInterface +from ..smartmodule import SmartModule, raise_if_update_error + + +class Energy(SmartModule, EnergyInterface): + """Implementation of energy monitoring module.""" + + REQUIRED_COMPONENT = "energy_monitoring" + + _energy: dict[str, Any] + _current_consumption: float | None + + async def _post_update_hook(self) -> None: + 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._energy.get("current_power")) is not None or ( + power := data.get("get_emeter_data", {}).get("power_mw") + ) is not None: + self._current_consumption = power / 1_000 + # Fallback if get_energy_usage does not provide current_power, + # which can happen on some newer devices (e.g. P304M). + # This may not be valid scenario as it pre-dates trying get_emeter_data + elif ( + power := self.data.get("get_current_power", {}).get("current_power") + ) is not None: + self._current_consumption = power + else: + self._current_consumption = None + + def query(self) -> dict: + """Query to execute during the update cycle.""" + req = { + "get_energy_usage": None, + } + if self.supported_version > 1: + req["get_current_power"] = None + req["get_emeter_data"] = None + req["get_emeter_vgain_igain"] = None + return req + + @property + def optional_response_keys(self) -> list[str]: + """Return optional response keys for the module.""" + if self.supported_version > 1: + return ["get_energy_usage"] + return [] + + @property + def current_consumption(self) -> float | None: + """Current power in watts.""" + return self._current_consumption + + @property + def energy(self) -> dict: + """Return get_energy_usage results.""" + return self._energy + + def _get_status_from_energy(self, energy: dict) -> EmeterStatus: + return EmeterStatus( + { + "power_mw": energy.get("current_power", 0), + "total": energy.get("today_energy", 0) / 1_000, + } + ) + + @property + @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) + + 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: + res = await self.call("get_energy_usage") + return self._get_status_from_energy(res["get_energy_usage"]) + + @property + def consumption_this_month(self) -> float | None: + """Get the emeter value for this month in kWh.""" + if (month := self.energy.get("month_energy")) is not None: + return month / 1_000 + return None + + @property + def consumption_today(self) -> float | None: + """Get the emeter value for today in kWh.""" + if (today := self.energy.get("today_energy")) is not None: + return today / 1_000 + return None + + @property + @raise_if_update_error + def consumption_total(self) -> float | None: + """Return total consumption since last reboot in kWh.""" + return None + + @property + @raise_if_update_error + def current(self) -> float | None: + """Return the current in A.""" + 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.""" + 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.""" + return self.status + + async def erase_stats(self) -> NoReturn: + """Erase all stats.""" + raise KasaException("Device does not support periodic statistics") + + async def get_daily_stats( + self, *, year: int | None = None, month: int | None = None, kwh: bool = True + ) -> dict: + """Return daily stats for the given year & month. + + The return value is a dictionary of {day: energy, ...}. + """ + raise KasaException("Device does not support periodic statistics") + + async def get_monthly_stats( + self, *, year: int | None = None, kwh: bool = True + ) -> dict: + """Return monthly stats for the given year.""" + raise KasaException("Device does not support periodic statistics") + + async def _check_supported(self) -> bool: + """Additional check to see if the module is supported by the device.""" + # Energy module is not supported on P304M parent device + return "device_on" in self._device.sys_info diff --git a/kasa/smart/modules/fan.py b/kasa/smart/modules/fan.py new file mode 100644 index 000000000..6443cbacb --- /dev/null +++ b/kasa/smart/modules/fan.py @@ -0,0 +1,79 @@ +"""Implementation of fan_control module.""" + +from __future__ import annotations + +from typing import Annotated + +from ...feature import Feature +from ...interfaces.fan import Fan as FanInterface +from ...module import FeatureAttribute +from ..smartmodule import SmartModule + + +class Fan(SmartModule, FanInterface): + """Implementation of fan_control module.""" + + REQUIRED_COMPONENT = "fan_control" + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + self._device, + id="fan_speed_level", + name="Fan speed level", + container=self, + attribute_getter="fan_speed_level", + attribute_setter="set_fan_speed_level", + icon="mdi:fan", + type=Feature.Type.Number, + range_getter=lambda: (0, 4), + category=Feature.Category.Primary, + ) + ) + self._add_feature( + Feature( + self._device, + id="fan_sleep_mode", + name="Fan sleep mode", + container=self, + attribute_getter="sleep_mode", + attribute_setter="set_sleep_mode", + icon="mdi:sleep", + type=Feature.Type.Switch, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + + @property + def fan_speed_level(self) -> Annotated[int, FeatureAttribute()]: + """Return fan speed level.""" + return 0 if self.data["device_on"] is False else self.data["fan_speed_level"] + + async def set_fan_speed_level( + self, level: int + ) -> Annotated[dict, FeatureAttribute()]: + """Set fan speed level, 0 for off, 1-4 for on.""" + if level < 0 or level > 4: + raise ValueError("Invalid level, should be in range 0-4.") + if level == 0: + return await self.call("set_device_info", {"device_on": False}) + return await self.call( + "set_device_info", {"device_on": True, "fan_speed_level": level} + ) + + @property + def sleep_mode(self) -> Annotated[bool, FeatureAttribute()]: + """Return sleep mode status.""" + return self.data["fan_sleep_mode_on"] + + async def set_sleep_mode(self, on: bool) -> Annotated[dict, FeatureAttribute()]: + """Set sleep mode.""" + return await self.call("set_device_info", {"fan_sleep_mode_on": on}) + + async def _check_supported(self) -> bool: + """Is the module available on this device.""" + return "fan_speed_level" in self.data diff --git a/kasa/smart/modules/firmware.py b/kasa/smart/modules/firmware.py new file mode 100644 index 000000000..8dd3a6b32 --- /dev/null +++ b/kasa/smart/modules/firmware.py @@ -0,0 +1,248 @@ +"""Implementation of firmware module.""" + +from __future__ import annotations + +import asyncio +import logging +from asyncio import timeout as asyncio_timeout +from collections.abc import Callable, Coroutine +from dataclasses import dataclass, field +from datetime import date +from typing import TYPE_CHECKING, Annotated + +from mashumaro import DataClassDictMixin, field_options +from mashumaro.types import Alias + +from ...exceptions import KasaException +from ...feature import Feature +from ..smartmodule import SmartModule, allow_update_after + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class DownloadState(DataClassDictMixin): + """Download state.""" + + # Example: + # {'status': 0, 'download_progress': 0, 'reboot_time': 5, + # 'upgrade_time': 5, 'auto_upgrade': False} + status: int + progress: Annotated[int, Alias("download_progress")] + reboot_time: int + upgrade_time: int + auto_upgrade: bool + + +@dataclass +class UpdateInfo(DataClassDictMixin): + """Update info status object.""" + + status: Annotated[int, Alias("type")] + needs_upgrade: Annotated[bool, Alias("need_to_upgrade")] + version: Annotated[str | None, Alias("fw_ver")] = None + release_date: date | None = field( + default=None, + metadata=field_options( + deserialize=lambda x: date.fromisoformat(x) if x else None + ), + ) + release_notes: Annotated[str | None, Alias("release_note")] = None + fw_size: int | None = None + oem_id: str | None = None + + @property + def update_available(self) -> bool: + """Return True if update available.""" + return self.status != 0 + + +class Firmware(SmartModule): + """Implementation of firmware module.""" + + REQUIRED_COMPONENT = "firmware" + MINIMUM_UPDATE_INTERVAL_SECS = 60 * 60 * 24 + + def __init__(self, device: SmartDevice, module: str) -> None: + super().__init__(device, module) + self._firmware_update_info: UpdateInfo | None = None + + def _initialize_features(self) -> None: + """Initialize features.""" + device = self._device + if self.supported_version > 1: + self._add_feature( + Feature( + device, + id="auto_update_enabled", + name="Auto update enabled", + container=self, + attribute_getter="auto_update_enabled", + attribute_setter="set_auto_update_enabled", + type=Feature.Type.Switch, + ) + ) + self._add_feature( + Feature( + device, + id="update_available", + name="Update available", + container=self, + attribute_getter="update_available", + type=Feature.Type.BinarySensor, + category=Feature.Category.Info, + ) + ) + self._add_feature( + Feature( + device, + id="current_firmware_version", + name="Current firmware version", + container=self, + attribute_getter="current_firmware", + category=Feature.Category.Debug, + type=Feature.Type.Sensor, + ) + ) + self._add_feature( + Feature( + device, + id="available_firmware_version", + name="Available firmware version", + container=self, + attribute_getter="latest_firmware", + category=Feature.Category.Debug, + type=Feature.Type.Sensor, + ) + ) + self._add_feature( + Feature( + device, + id="check_latest_firmware", + name="Check latest firmware", + container=self, + attribute_setter="check_latest_firmware", + category=Feature.Category.Info, + type=Feature.Type.Action, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + if self.supported_version > 1: + return {"get_auto_update_info": None} + return {} + + async def check_latest_firmware(self) -> UpdateInfo | None: + """Check for the latest firmware for the device.""" + try: + fw = await self.call("get_latest_fw") + self._firmware_update_info = UpdateInfo.from_dict(fw["get_latest_fw"]) + return self._firmware_update_info + except Exception: + _LOGGER.exception("Error getting latest firmware for %s:", self._device) + self._firmware_update_info = None + return None + + @property + def current_firmware(self) -> str: + """Return the current firmware version.""" + return self._device.hw_info["sw_ver"] + + @property + def latest_firmware(self) -> str | None: + """Return the latest firmware version.""" + if not self._firmware_update_info: + return None + return self._firmware_update_info.version + + @property + def firmware_update_info(self) -> UpdateInfo | None: + """Return latest firmware information.""" + return self._firmware_update_info + + @property + def update_available(self) -> bool | None: + """Return True if update is available.""" + if not self._device.is_cloud_connected or not self._firmware_update_info: + return None + return self._firmware_update_info.update_available + + async def get_update_state(self) -> DownloadState: + """Return update state.""" + resp = await self.call("get_fw_download_state") + state = resp["get_fw_download_state"] + return DownloadState.from_dict(state) + + @allow_update_after + async def update( + self, progress_cb: Callable[[DownloadState], Coroutine] | None = None + ) -> dict: + """Update the device firmware.""" + if not self._firmware_update_info: + raise KasaException( + "You must call check_latest_firmware before calling update" + ) + if not self.update_available: + raise KasaException("A new update must be available to call update") + current_fw = self.current_firmware + _LOGGER.info( + "Going to upgrade from %s to %s", + current_fw, + self._firmware_update_info.version, + ) + await self.call("fw_download") + + # TODO: read timeout from get_auto_update_info or from get_fw_download_state? + async with asyncio_timeout(60 * 5): + while True: + await asyncio.sleep(0.5) + try: + state = await self.get_update_state() + except Exception as ex: + _LOGGER.warning( + "Got exception, maybe the device is rebooting? %s", ex + ) + continue + + _LOGGER.debug("Update state: %s", state) + if progress_cb is not None: + asyncio.create_task(progress_cb(state)) + + if state.status == 0: + _LOGGER.info( + "Update idle, hopefully updated to %s", + self._firmware_update_info.version, + ) + break + elif state.status == 2: + _LOGGER.info("Downloading firmware, progress: %s", state.progress) + elif state.status == 3: + upgrade_sleep = state.upgrade_time + _LOGGER.info( + "Flashing firmware, sleeping for %s before checking status", + upgrade_sleep, + ) + await asyncio.sleep(upgrade_sleep) + elif state.status < 0: + _LOGGER.error("Got error: %s", state.status) + break + else: + _LOGGER.warning("Unhandled state code: %s", state) + + return state.to_dict() + + @property + def auto_update_enabled(self) -> bool: + """Return True if autoupdate is enabled.""" + return "enable" in self.data and self.data["enable"] + + @allow_update_after + async def set_auto_update_enabled(self, enabled: bool) -> dict: + """Change autoupdate setting.""" + data = {**self.data, "enable": enabled} + return await self.call("set_auto_update_info", data) diff --git a/kasa/smart/modules/frostprotection.py b/kasa/smart/modules/frostprotection.py new file mode 100644 index 000000000..dd3671a05 --- /dev/null +++ b/kasa/smart/modules/frostprotection.py @@ -0,0 +1,41 @@ +"""Frost protection module.""" + +from __future__ import annotations + +from ..smartmodule import SmartModule + + +class FrostProtection(SmartModule): + """Implementation for frost protection module. + + This basically turns the thermostat on and off. + """ + + REQUIRED_COMPONENT = "frost_protection" + QUERY_GETTER_NAME = "get_frost_protection" + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + + @property + def enabled(self) -> bool: + """Return True if frost protection is on.""" + return self._device.sys_info["frost_protection_on"] + + async def set_enabled(self, enable: bool) -> dict: + """Enable/disable frost protection.""" + return await self.call( + "set_device_info", + {"frost_protection_on": enable}, + ) + + @property + def minimum_temperature(self) -> int: + """Return frost protection minimum temperature.""" + return self.data["min_temp"] + + @property + def temperature_unit(self) -> str: + """Return frost protection temperature unit.""" + return self.data["temp_unit"] 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/humiditysensor.py b/kasa/smart/modules/humiditysensor.py new file mode 100644 index 000000000..8ce9e576f --- /dev/null +++ b/kasa/smart/modules/humiditysensor.py @@ -0,0 +1,55 @@ +"""Implementation of humidity module.""" + +from __future__ import annotations + +from ...feature import Feature +from ..smartmodule import SmartModule + + +class HumiditySensor(SmartModule): + """Implementation of humidity module.""" + + REQUIRED_COMPONENT = "humidity" + QUERY_GETTER_NAME = "get_comfort_humidity_config" + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + self._device, + id="humidity", + name="Humidity", + container=self, + attribute_getter="humidity", + icon="mdi:water-percent", + unit_getter=lambda: "%", + category=Feature.Category.Primary, + type=Feature.Type.Sensor, + ) + ) + self._add_feature( + Feature( + self._device, + id="humidity_warning", + name="Humidity warning", + container=self, + attribute_getter="humidity_warning", + type=Feature.Type.BinarySensor, + icon="mdi:alert", + category=Feature.Category.Debug, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + + @property + def humidity(self) -> int: + """Return current humidity in percentage.""" + return self._device.sys_info["current_humidity"] + + @property + def humidity_warning(self) -> bool: + """Return true if humidity is outside of the wanted range.""" + return self._device.sys_info["current_humidity_exception"] != 0 diff --git a/kasa/smart/modules/led.py b/kasa/smart/modules/led.py new file mode 100644 index 000000000..1733c3ce4 --- /dev/null +++ b/kasa/smart/modules/led.py @@ -0,0 +1,52 @@ +"""Module for led controls.""" + +from __future__ import annotations + +from ...interfaces.led import Led as LedInterface +from ..smartmodule import SmartModule, allow_update_after + + +class Led(SmartModule, LedInterface): + """Implementation of led controls.""" + + REQUIRED_COMPONENT = "led" + QUERY_GETTER_NAME = "get_led_info" + # Led queries can cause device to crash on P100 + MINIMUM_UPDATE_INTERVAL_SECS = 60 * 60 + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {self.QUERY_GETTER_NAME: None} + + @property + def mode(self) -> str: + """LED mode setting. + + "always", "never", "night_mode" + """ + return self.data["led_rule"] + + @property + def led(self) -> bool: + """Return current led status.""" + return self.data["led_rule"] != "never" + + @allow_update_after + async def set_led(self, enable: bool) -> dict: + """Set led. + + This should probably be a select with always/never/nightmode. + """ + rule = "always" if enable else "never" + return await self.call("set_led_info", dict(self.data, **{"led_rule": rule})) + + @property + def night_mode_settings(self) -> dict: + """Night mode settings.""" + return { + "start": self.data["start_time"], + "end": self.data["end_time"], + "type": self.data["night_mode_type"], + "sunrise_offset": self.data["sunrise_offset"], + "sunset_offset": self.data["sunset_offset"], + } diff --git a/kasa/smart/modules/light.py b/kasa/smart/modules/light.py new file mode 100644 index 000000000..d548811f5 --- /dev/null +++ b/kasa/smart/modules/light.py @@ -0,0 +1,152 @@ +"""Module for led controls.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Annotated + +from ...exceptions import KasaException +from ...feature import Feature +from ...interfaces.light import HSV, LightState +from ...interfaces.light import Light as LightInterface +from ...module import FeatureAttribute, Module +from ..smartmodule import SmartModule + + +class Light(SmartModule, LightInterface): + """Implementation of a light.""" + + _light_state: LightState + + @property + def _all_features(self) -> dict[str, Feature]: + """Get the features for this module and any sub modules.""" + ret: dict[str, Feature] = {} + if brightness := self._device.modules.get(Module.Brightness): + ret.update(**brightness._module_features) + if color := self._device.modules.get(Module.Color): + ret.update(**color._module_features) + if temp := self._device.modules.get(Module.ColorTemperature): + ret.update(**temp._module_features) + return ret + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + + @property + def hsv(self) -> Annotated[HSV, FeatureAttribute()]: + """Return the current HSV state of the bulb. + + :return: hue, saturation and value (degrees, %, %) + """ + if Module.Color not in self._device.modules: + raise KasaException("Bulb does not support color.") + + return self._device.modules[Module.Color].hsv + + @property + def color_temp(self) -> Annotated[int, FeatureAttribute()]: + """Whether the bulb supports color temperature changes.""" + if Module.ColorTemperature not in self._device.modules: + raise KasaException("Bulb does not support colortemp.") + + return self._device.modules[Module.ColorTemperature].color_temp + + @property + def brightness(self) -> Annotated[int, FeatureAttribute()]: + """Return the current brightness in percentage.""" + if Module.Brightness not in self._device.modules: # pragma: no cover + raise KasaException("Bulb is not dimmable.") + + return self._device.modules[Module.Brightness].brightness + + async def set_hsv( + self, + hue: int, + saturation: int, + value: int | None = None, + *, + transition: int | None = None, + ) -> Annotated[dict, FeatureAttribute()]: + """Set new HSV. + + Note, transition is not supported and will be ignored. + + :param int hue: hue in degrees + :param int saturation: saturation in percentage [0,100] + :param int value: value between 1 and 100 + :param int transition: transition in milliseconds. + """ + 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) + + async def set_color_temp( + self, temp: int, *, brightness: int | None = None, transition: int | None = None + ) -> Annotated[dict, FeatureAttribute()]: + """Set the color temperature of the device in kelvin. + + Note, transition is not supported and will be ignored. + + :param int temp: The new color temperature, in Kelvin + :param int transition: transition in milliseconds. + """ + 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 + ) + + async def set_brightness( + self, brightness: int, *, transition: int | None = None + ) -> Annotated[dict, FeatureAttribute()]: + """Set the brightness in percentage. + + Note, transition is not supported and will be ignored. + + :param int brightness: brightness in percent + :param int transition: transition in milliseconds. + """ + 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) + + async def set_state(self, state: LightState) -> dict: + """Set the light state.""" + state_dict = asdict(state) + # brightness of 0 turns off the light, it's not a valid brightness + if state.brightness == 0: + state_dict["device_on"] = False + del state_dict["brightness"] + elif state.light_on is not None: + state_dict["device_on"] = state.light_on + del state_dict["light_on"] + else: + state_dict["device_on"] = True + + params = {k: v for k, v in state_dict.items() if v is not None} + return await self.call("set_device_info", params) + + @property + def state(self) -> LightState: + """Return the current light state.""" + return self._light_state + + async def _post_update_hook(self) -> None: + device = self._device + if device.is_on is False: + state = LightState(light_on=False) + else: + state = LightState(light_on=True) + if Module.Brightness in device.modules: + state.brightness = self.brightness + if Module.Color in device.modules: + hsv = self.hsv + state.hue = hsv.hue + state.saturation = hsv.saturation + if Module.ColorTemperature in device.modules: + state.color_temp = self.color_temp + self._light_state = state diff --git a/kasa/smart/modules/lighteffect.py b/kasa/smart/modules/lighteffect.py new file mode 100644 index 000000000..96135de47 --- /dev/null +++ b/kasa/smart/modules/lighteffect.py @@ -0,0 +1,183 @@ +"""Module for light effects.""" + +from __future__ import annotations + +import base64 +import binascii +import contextlib +import copy +from typing import Any + +from ..effects import SmartLightEffect +from ..smartmodule import Module, SmartModule, allow_update_after + + +class LightEffect(SmartModule, SmartLightEffect): + """Implementation of dynamic light effects.""" + + REQUIRED_COMPONENT = "light_effect" + QUERY_GETTER_NAME = "get_dynamic_light_effect_rules" + MINIMUM_UPDATE_INTERVAL_SECS = 60 * 60 * 24 + AVAILABLE_BULB_EFFECTS = { + "L1": "Party", + "L2": "Relax", + } + + _effect: str + _effect_state_list: dict[str, dict[str, Any]] + _effect_list: list[str] + _scenes_names_to_id: dict[str, str] + + async def _post_update_hook(self) -> None: + """Update internal effect state.""" + # Copy the effects so scene name updates do not update the underlying dict. + effects = copy.deepcopy( + {effect["id"]: effect for effect in self.data["rule_list"]} + ) + for effect in effects.values(): + if not effect["scene_name"]: + # If the name has not been edited scene_name will be an empty string + effect["scene_name"] = self.AVAILABLE_BULB_EFFECTS[effect["id"]] + else: + # Otherwise it might be b64 encoded or raw string + with contextlib.suppress(binascii.Error): + effect["scene_name"] = base64.b64decode( + effect["scene_name"] + ).decode() + + self._effect_state_list = effects + self._effect_list = [self.LIGHT_EFFECTS_OFF] + self._effect_list.extend([effect["scene_name"] for effect in effects.values()]) + self._scenes_names_to_id = { + effect["scene_name"]: effect["id"] for effect in effects.values() + } + # get_dynamic_light_effect_rules also has an enable property and current_rule_id + # property that could be used here as an alternative + if self._device._info["dynamic_light_effect_enable"]: + self._effect = self._effect_state_list[ + self._device._info["dynamic_light_effect_id"] + ]["scene_name"] + else: + self._effect = self.LIGHT_EFFECTS_OFF + + @property + def effect_list(self) -> list[str]: + """Return built-in effects list. + + Example: + ['Party', 'Relax', ...] + """ + return self._effect_list + + @property + def effect(self) -> str: + """Return effect name.""" + return self._effect + + @allow_update_after + async def set_effect( + self, + effect: str, + *, + brightness: int | None = None, + transition: int | None = None, + ) -> dict: + """Set an effect for the device. + + Calling this will modify the brightness of the effect on the device. + + The device doesn't store an active effect while not enabled so store locally. + """ + if effect != self.LIGHT_EFFECTS_OFF and effect not in self._scenes_names_to_id: + raise ValueError( + f"The effect {effect} is not a built in effect. Possible values " + f"are: {self.LIGHT_EFFECTS_OFF} " + f"{' '.join(self._scenes_names_to_id.keys())}" + ) + enable = effect != self.LIGHT_EFFECTS_OFF + params: dict[str, bool | str] = {"enable": enable} + if enable: + effect_id = self._scenes_names_to_id[effect] + params["id"] = effect_id + + # We set the wanted brightness before activating the effect + brightness_module = self._device.modules[Module.Brightness] + brightness = ( + brightness if brightness is not None else brightness_module.brightness + ) + await self.set_brightness(brightness, effect_id=effect_id) + + return await self.call("set_dynamic_light_effect_rule_enable", params) + + @property + def is_active(self) -> bool: + """Return True if effect is active.""" + return bool(self._device._info["dynamic_light_effect_enable"]) + + def _get_effect_data(self, effect_id: str | None = None) -> dict[str, Any]: + """Return effect data for the *effect_id*. + + If *effect_id* is None, return the data for active effect. + """ + if effect_id is None: + effect_id = self.data["current_rule_id"] + + return self._effect_state_list[effect_id] + + @property + def brightness(self) -> int: + """Return effect brightness.""" + first_color_status = self._get_effect_data()["color_status_list"][0] + brightness = first_color_status[0] + + return brightness + + @allow_update_after + async def set_brightness( + self, + brightness: int, + *, + transition: int | None = None, + effect_id: str | None = None, + ) -> dict: + """Set effect brightness.""" + new_effect = self._get_effect_data(effect_id=effect_id).copy() + + def _replace_brightness(data: list[int], new_brightness: int) -> list[int]: + """Replace brightness. + + The first element is the brightness, the rest are unknown. + [[33, 0, 0, 2700], [33, 321, 99, 0], [33, 196, 99, 0], .. ] + """ + return [new_brightness, data[1], data[2], data[3]] + + new_color_status_list = [ + _replace_brightness(state, brightness) + for state in new_effect["color_status_list"] + ] + new_effect["color_status_list"] = new_color_status_list + + return await self.call("edit_dynamic_light_effect_rule", new_effect) + + @allow_update_after + async def set_custom_effect( + self, + effect_dict: dict, + ) -> dict: + """Set a custom effect on the device. + + :param str effect_dict: The custom effect dict to set + """ + raise NotImplementedError( + "Device does not support setting custom effects. " + "Use has_custom_effects to check for support." + ) + + @property + def has_custom_effects(self) -> bool: + """Return True if the device supports setting custom effects.""" + return False + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {self.QUERY_GETTER_NAME: {"start_index": 0}} diff --git a/kasa/smart/modules/lightpreset.py b/kasa/smart/modules/lightpreset.py new file mode 100644 index 000000000..87e96eaee --- /dev/null +++ b/kasa/smart/modules/lightpreset.py @@ -0,0 +1,176 @@ +"""Module for light effects.""" + +from __future__ import annotations + +import logging +from collections.abc import Sequence +from dataclasses import asdict +from typing import TYPE_CHECKING + +from ...interfaces import LightPreset as LightPresetInterface +from ...interfaces import LightState +from ..smartmodule import SmartModule, allow_update_after + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + +_LOGGER = logging.getLogger(__name__) + + +class LightPreset(SmartModule, LightPresetInterface): + """Implementation of light presets.""" + + REQUIRED_COMPONENT = "preset" + QUERY_GETTER_NAME = "get_preset_rules" + MINIMUM_UPDATE_INTERVAL_SECS = 60 + + SYS_INFO_STATE_KEY = "preset_state" + + _presets: dict[str, LightState] + _preset_list: list[str] + + def __init__(self, device: SmartDevice, module: str) -> None: + super().__init__(device, module) + self._state_in_sysinfo = self.SYS_INFO_STATE_KEY in device.sys_info + self._brightness_only: bool = False + + async def _post_update_hook(self) -> None: + """Update the internal presets.""" + index = 0 + self._presets = {} + + state_key = "states" if not self._state_in_sysinfo else self.SYS_INFO_STATE_KEY + if preset_states := self.data.get(state_key): + for preset_state in preset_states: + if "brightness" not in preset_state: + # Some devices can store effects as a preset. These will be ignored + # and handled in the effects module + if "lighting_effect" not in preset_state: + _LOGGER.info( + "Unexpected keys %s in preset", list(preset_state.keys()) + ) + continue + color_temp = preset_state.get("color_temp") + hue = preset_state.get("hue") + saturation = preset_state.get("saturation") + self._presets[f"Light preset {index + 1}"] = LightState( + brightness=preset_state["brightness"], + color_temp=color_temp, + hue=hue, + saturation=saturation, + ) + if color_temp is None and hue is None and saturation is None: + self._brightness_only = True + index = index + 1 + elif preset_brightnesses := self.data.get("brightness"): + self._brightness_only = True + for preset_brightness in preset_brightnesses: + self._presets[f"Brightness preset {index + 1}"] = LightState( + brightness=preset_brightness, + ) + index = index + 1 + + self._preset_list = [self.PRESET_NOT_SET] + self._preset_list.extend(self._presets.keys()) + + @property + def preset_list(self) -> list[str]: + """Return built-in effects list. + + Example: + ['Off', 'Light preset 1', 'Light preset 2', ...] + """ + return self._preset_list + + @property + def preset_states_list(self) -> Sequence[LightState]: + """Return built-in effects list. + + Example: + ['Off', 'Preset 1', 'Preset 2', ...] + """ + return list(self._presets.values()) + + @property + def preset(self) -> str: + """Return current preset name.""" + light = self._device.modules[SmartModule.Light] + brightness = light.brightness + 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.has_feature("color_temp") + ) + and preset.hue == h + and preset.saturation == s + ): + return preset_name + return self.PRESET_NOT_SET + + async def set_preset( + self, + preset_name: str, + ) -> dict: + """Set a light preset for the device.""" + light = self._device.modules[SmartModule.Light] + if preset_name == self.PRESET_NOT_SET: + if light.has_feature("hsv"): + preset = LightState(hue=0, saturation=0, brightness=100) + else: + preset = LightState(brightness=100) + elif (preset := self._presets.get(preset_name)) is None: # type: ignore[assignment] + raise ValueError(f"{preset_name} is not a valid preset: {self.preset_list}") + return await self._device.modules[SmartModule.Light].set_state(preset) + + @allow_update_after + async def save_preset( + self, + preset_name: str, + preset_state: LightState, + ) -> dict: + """Update the preset with preset_name with the new preset_info.""" + if preset_name not in self._presets: + raise ValueError(f"{preset_name} is not a valid preset: {self.preset_list}") + index = list(self._presets.keys()).index(preset_name) + if self._brightness_only: + bright_list = [state.brightness for state in self._presets.values()] + bright_list[index] = preset_state.brightness + return await self.call("set_preset_rules", {"brightness": bright_list}) + else: + state_params = asdict(preset_state) + new_info = {k: v for k, v in state_params.items() if v is not None} + return await self.call( + "edit_preset_rules", {"index": index, "state": new_info} + ) + + @property + def has_save_preset(self) -> bool: + """Return True if the device supports updating presets.""" + return True + + def query(self) -> dict: + """Query to execute during the update cycle.""" + if self._state_in_sysinfo: # Child lights can have states in the child info + return {} + if self.supported_version < 3: + return {self.QUERY_GETTER_NAME: None} + + return {self.QUERY_GETTER_NAME: {"start_index": 0}} + + async def _check_supported(self) -> bool: + """Additional check to see if the module is supported by the device. + + Parent devices that report components of children such as ks240 will not have + the brightness value is sysinfo. + """ + # Look in _device.sys_info here because self.data is either sys_info or + # get_preset_rules depending on whether it's a child device or not. + return "brightness" in self._device.sys_info diff --git a/kasa/smart/modules/lightstripeffect.py b/kasa/smart/modules/lightstripeffect.py new file mode 100644 index 000000000..34c1c20c2 --- /dev/null +++ b/kasa/smart/modules/lightstripeffect.py @@ -0,0 +1,181 @@ +"""Module for light effects.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ..effects import EFFECT_MAPPING, EFFECT_NAMES, SmartLightEffect +from ..smartmodule import Module, SmartModule, allow_update_after + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +class LightStripEffect(SmartModule, SmartLightEffect): + """Implementation of dynamic light effects.""" + + REQUIRED_COMPONENT = "light_strip_lighting_effect" + + def __init__(self, device: SmartDevice, module: str) -> None: + super().__init__(device, module) + effect_list = [self.LIGHT_EFFECTS_OFF] + effect_list.extend(EFFECT_NAMES) + self._effect_list = effect_list + self._effect_mapping = EFFECT_MAPPING + + @property + def name(self) -> str: + """Name of the module. + + By default smart modules are keyed in the module mapping by class name. + The name is overriden here as this module implements the same common interface + as the bulb light_effect and the assumption is a device only supports one + or the other. + + """ + return "LightEffect" + + @property + def effect(self) -> str: + """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 + def is_active(self) -> bool: + """Return if effect is active.""" + eff = self.data["lighting_effect"] + # softAP has enable=1, but brightness 0 which fails on tests + return bool(eff["enable"]) and eff["name"] in self._effect_list + + @property + def brightness(self) -> int: + """Return effect brightness.""" + eff = self.data["lighting_effect"] + return eff["brightness"] + + async def set_brightness( + self, brightness: int, *, transition: int | None = None + ) -> dict: + """Set effect brightness.""" + if brightness <= 0: + return await self.set_effect(self.LIGHT_EFFECTS_OFF) + + # Need to pass bAdjusted to keep the existing effect running + eff = {"brightness": brightness, "bAdjusted": True} + return await self.set_custom_effect(eff) + + @property + def effect_list(self) -> list[str]: + """Return built-in effects list. + + Example: + ['Aurora', 'Bubbling Cauldron', ...] + """ + return self._effect_list + + @allow_update_after + async def set_effect( + self, + effect: str, + *, + brightness: int | None = None, + transition: int | None = None, + ) -> dict: + """Set an effect on the device. + + If brightness or transition is defined, + its value will be used instead of the effect-specific default. + + See :meth:`effect_list` for available effects, + or use :meth:`set_custom_effect` for custom effects. + + :param str effect: The effect to set + :param int brightness: The wanted brightness + :param int transition: The wanted transition time + """ + brightness_module = self._device.modules[Module.Brightness] + if effect == self.LIGHT_EFFECTS_OFF: + if self.effect in self._effect_mapping: + # TODO: We could query get_lighting_effect here to + # get the custom effect although not sure how to find + # custom effects + effect_dict = self._effect_mapping[self.effect] + else: + effect_dict = self._effect_mapping["Aurora"] + effect_dict = {**effect_dict} + effect_dict["enable"] = 0 + return await self.set_custom_effect(effect_dict) + + if effect not in self._effect_mapping: + raise ValueError(f"The effect {effect} is not a built in effect.") + else: + effect_dict = self._effect_mapping[effect] + effect_dict = {**effect_dict} + + # Use explicitly given brightness + if brightness is not None: + effect_dict["brightness"] = brightness + # Fall back to brightness reported by the brightness module + elif brightness_module.brightness: + effect_dict["brightness"] = brightness_module.brightness + + if transition is not None: + effect_dict["transition"] = transition + + return await self.set_custom_effect(effect_dict) + + @allow_update_after + async def set_custom_effect( + self, + effect_dict: dict, + ) -> dict: + """Set a custom effect on the device. + + :param str effect_dict: The custom effect dict to set + """ + return await self.call( + "set_lighting_effect", + effect_dict, + ) + + @property + def has_custom_effects(self) -> bool: + """Return True if the device supports setting custom effects.""" + return True + + def query(self) -> dict: + """Return the base query.""" + return {} + + @property # type: ignore + def _deprecated_effect(self) -> dict: + """Return effect state. + + Example: + {'brightness': 50, + 'custom': 0, + 'enable': 0, + 'id': '', + 'name': ''} + """ + # LightEffectModule returns the current effect name + # so return the dict here for backwards compatibility + return self.data["lighting_effect"] + + @property # type: ignore + def _deprecated_effect_list(self) -> list[str] | None: + """Return built-in effects list. + + Example: + ['Aurora', 'Bubbling Cauldron', ...] + """ + # LightEffectModule returns effect names along with a LIGHT_EFFECTS_OFF value + # so return the original effect names here for backwards compatibility + return EFFECT_NAMES diff --git a/kasa/smart/modules/lighttransition.py b/kasa/smart/modules/lighttransition.py new file mode 100644 index 000000000..e623108fe --- /dev/null +++ b/kasa/smart/modules/lighttransition.py @@ -0,0 +1,260 @@ +"""Module for smooth light transitions.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, TypedDict + +from ...exceptions import KasaException +from ...feature import Feature +from ..smartmodule import SmartModule, allow_update_after + +if TYPE_CHECKING: + from ..smartdevice import SmartDevice + + +class _State(TypedDict): + duration: int + enable: bool + max_duration: int + + +class LightTransition(SmartModule): + """Implementation of gradual on/off.""" + + REQUIRED_COMPONENT = "on_off_gradually" + QUERY_GETTER_NAME = "get_on_off_gradually_info" + MINIMUM_UPDATE_INTERVAL_SECS = 60 + # v3 added max_duration, we default to 60 when it's not available + MAXIMUM_DURATION = 60 + + # Key in sysinfo that indicates state can be retrieved from there. + # Usually only for child lights, i.e, ks240. + SYS_INFO_STATE_KEYS = ( + "gradually_on_mode", + "gradually_off_mode", + "fade_on_time", + "fade_off_time", + ) + + _on_state: _State + _off_state: _State + _enabled: bool + + def __init__(self, device: SmartDevice, module: str) -> None: + super().__init__(device, module) + self._state_in_sysinfo = all( + key in device.sys_info for key in self.SYS_INFO_STATE_KEYS + ) + self._supports_on_and_off: bool = self.supported_version > 1 + + def _initialize_features(self) -> None: + """Initialize features.""" + icon = "mdi:transition" + if not self._supports_on_and_off: + self._add_feature( + Feature( + device=self._device, + container=self, + id="smooth_transitions", + name="Smooth transitions", + icon=icon, + attribute_getter="enabled", + attribute_setter="set_enabled", + type=Feature.Type.Switch, + ) + ) + else: + self._add_feature( + Feature( + self._device, + id="smooth_transition_on", + name="Smooth transition on", + container=self, + attribute_getter="turn_on_transition", + attribute_setter="set_turn_on_transition", + icon=icon, + type=Feature.Type.Number, + range_getter=lambda: (0, self._turn_on_transition_max), + ) + ) + self._add_feature( + Feature( + self._device, + id="smooth_transition_off", + name="Smooth transition off", + container=self, + attribute_getter="turn_off_transition", + attribute_setter="set_turn_off_transition", + icon=icon, + type=Feature.Type.Number, + range_getter=lambda: (0, self._turn_off_transition_max), + ) + ) + + async def _post_update_hook(self) -> None: + """Update the states.""" + # Assumes any device with state in sysinfo supports on and off and + # has maximum values for both. + # v2 adds separate on & off states + # v3 adds max_duration except for ks240 which is v2 but supports it + if not self._supports_on_and_off: + self._enabled = self.data["enable"] + return + + if self._state_in_sysinfo: + on_max = self._device.sys_info.get( + "max_fade_on_time", self.MAXIMUM_DURATION + ) + off_max = self._device.sys_info.get( + "max_fade_off_time", self.MAXIMUM_DURATION + ) + on_enabled = bool(self._device.sys_info["gradually_on_mode"]) + off_enabled = bool(self._device.sys_info["gradually_off_mode"]) + on_duration = self._device.sys_info["fade_on_time"] + off_duration = self._device.sys_info["fade_off_time"] + elif (on_state := self.data.get("on_state")) and ( + off_state := self.data.get("off_state") + ): + on_max = on_state.get("max_duration", self.MAXIMUM_DURATION) + off_max = off_state.get("max_duration", self.MAXIMUM_DURATION) + on_enabled = on_state["enable"] + off_enabled = off_state["enable"] + on_duration = on_state["duration"] + off_duration = off_state["duration"] + else: + raise KasaException( + f"Unsupported for {self.REQUIRED_COMPONENT} v{self.supported_version}" + ) + + self._enabled = on_enabled or off_enabled + self._on_state = { + "duration": on_duration, + "enable": on_enabled, + "max_duration": on_max, + } + self._off_state = { + "duration": off_duration, + "enable": off_enabled, + "max_duration": off_max, + } + + @allow_update_after + async def set_enabled(self, enable: bool) -> dict: + """Enable gradual on/off.""" + if not self._supports_on_and_off: + return await self.call("set_on_off_gradually_info", {"enable": enable}) + else: + on = await self.call( + "set_on_off_gradually_info", + { + "on_state": { + "enable": enable, + "duration": self._on_state["duration"], + } + }, + ) + off = await self.call( + "set_on_off_gradually_info", + { + "off_state": { + "enable": enable, + "duration": self._off_state["duration"], + } + }, + ) + return {**on, **off} + + @property + def enabled(self) -> bool: + """Return True if gradual on/off is enabled.""" + return self._enabled + + @property + def turn_on_transition(self) -> int: + """Return transition time for turning the light on. + + Available only from v2. + """ + return self._on_state["duration"] if self._on_state["enable"] else 0 + + @property + def _turn_on_transition_max(self) -> int: + """Maximum turn on duration.""" + return self._on_state["max_duration"] + + @allow_update_after + async def set_turn_on_transition(self, seconds: int) -> dict: + """Set turn on transition in seconds. + + Setting to 0 turns the feature off. + """ + if seconds > self._turn_on_transition_max: + raise ValueError( + f"Value {seconds} out of range, max {self._turn_on_transition_max}" + ) + + if seconds <= 0: + return await self.call( + "set_on_off_gradually_info", + {"on_state": {"enable": False, "duration": self._on_state["duration"]}}, + ) + + return await self.call( + "set_on_off_gradually_info", + {"on_state": {"enable": True, "duration": seconds}}, + ) + + @property + def turn_off_transition(self) -> int: + """Return transition time for turning the light off. + + Available only from v2. + """ + return self._off_state["duration"] if self._off_state["enable"] else 0 + + @property + def _turn_off_transition_max(self) -> int: + """Maximum turn on duration.""" + # v3 added max_duration, we default to 60 when it's not available + return self._off_state["max_duration"] + + @allow_update_after + async def set_turn_off_transition(self, seconds: int) -> dict: + """Set turn on transition in seconds. + + Setting to 0 turns the feature off. + """ + if seconds > self._turn_off_transition_max: + raise ValueError( + f"Value {seconds} out of range, max {self._turn_off_transition_max}" + ) + + if seconds <= 0: + return await self.call( + "set_on_off_gradually_info", + { + "off_state": { + "enable": False, + "duration": self._off_state["duration"], + } + }, + ) + + return await self.call( + "set_on_off_gradually_info", + {"off_state": {"enable": True, "duration": seconds}}, + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + # Some devices have the required info in the device info. + if self._state_in_sysinfo: + return {} + else: + return {self.QUERY_GETTER_NAME: None} + + async def _check_supported(self) -> bool: + """Additional check to see if the module is supported by the device.""" + # For devices that report child components on the parent that are not + # actually supported by the parent. + return "brightness" in self._device.sys_info 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/motionsensor.py b/kasa/smart/modules/motionsensor.py new file mode 100644 index 000000000..fe9ac5c00 --- /dev/null +++ b/kasa/smart/modules/motionsensor.py @@ -0,0 +1,36 @@ +"""Implementation of motion sensor module.""" + +from __future__ import annotations + +from ...feature import Feature +from ..smartmodule import SmartModule + + +class MotionSensor(SmartModule): + """Implementation of motion sensor module.""" + + REQUIRED_COMPONENT = "sensitivity" + + def _initialize_features(self) -> None: + """Initialize features.""" + self._add_feature( + Feature( + self._device, + id="motion_detected", + name="Motion detected", + container=self, + attribute_getter="motion_detected", + icon="mdi:motion-sensor", + category=Feature.Category.Primary, + type=Feature.Type.BinarySensor, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + + @property + def motion_detected(self) -> bool: + """Return True if the motion has been detected.""" + return self._device.sys_info["detected"] 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/reportmode.py b/kasa/smart/modules/reportmode.py new file mode 100644 index 000000000..4765b4e13 --- /dev/null +++ b/kasa/smart/modules/reportmode.py @@ -0,0 +1,37 @@ +"""Implementation of report module.""" + +from __future__ import annotations + +from ...feature import Feature +from ..smartmodule import SmartModule + + +class ReportMode(SmartModule): + """Implementation of report module.""" + + REQUIRED_COMPONENT = "report_mode" + QUERY_GETTER_NAME = "get_report_mode" + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + self._device, + id="report_interval", + name="Report interval", + container=self, + attribute_getter="report_interval", + unit_getter=lambda: "s", + category=Feature.Category.Debug, + type=Feature.Type.Sensor, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + + @property + def report_interval(self) -> int: + """Reporting interval of a sensor device.""" + return self._device.sys_info["report_interval"] 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 new file mode 100644 index 000000000..b8db7b84f --- /dev/null +++ b/kasa/smart/modules/temperaturecontrol.py @@ -0,0 +1,168 @@ +"""Implementation of temperature control module.""" + +from __future__ import annotations + +import logging + +from ...feature import Feature +from ...interfaces.thermostat import ThermostatState +from ..smartmodule import SmartModule + +_LOGGER = logging.getLogger(__name__) + + +class TemperatureControl(SmartModule): + """Implementation of temperature module.""" + + REQUIRED_COMPONENT = "temp_control" + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + self._device, + id="target_temperature", + name="Target temperature", + container=self, + attribute_getter="target_temperature", + attribute_setter="set_target_temperature", + range_getter="allowed_temperature_range", + icon="mdi:thermometer", + type=Feature.Type.Number, + category=Feature.Category.Primary, + ) + ) + # TODO: this might belong into its own module, temperature_correction? + self._add_feature( + Feature( + self._device, + id="temperature_offset", + name="Temperature offset", + container=self, + attribute_getter="temperature_offset", + attribute_setter="set_temperature_offset", + range_getter=lambda: (-10, 10), + type=Feature.Type.Number, + category=Feature.Category.Config, + ) + ) + self._add_feature( + Feature( + self._device, + id="state", + name="State", + container=self, + attribute_getter="state", + attribute_setter="set_state", + category=Feature.Category.Primary, + type=Feature.Type.Switch, + ) + ) + self._add_feature( + Feature( + self._device, + id="thermostat_mode", + name="Thermostat mode", + container=self, + attribute_getter="mode", + category=Feature.Category.Primary, + type=Feature.Type.Sensor, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + # Target temperature is contained in the main device info response. + return {} + + @property + def state(self) -> bool: + """Return thermostat state.""" + return self._device.sys_info["frost_protection_on"] is False + + async def set_state(self, enabled: bool) -> dict: + """Set thermostat state.""" + return await self.call("set_device_info", {"frost_protection_on": not enabled}) + + @property + def mode(self) -> ThermostatState: + """Return thermostat state.""" + # If frost protection is enabled, the thermostat is off. + if self._device.sys_info.get("frost_protection_on", False): + return ThermostatState.Off + + 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 + + # Report on unknown extra states + if len(states) > 1: + _LOGGER.warning("Got multiple states: %s", states) + + # Return the first known state + for state in ThermostatState: + if state.value in states: + return state + + _LOGGER.warning("Got unknown state: %s", states) + return ThermostatState.Unknown + + @property + def allowed_temperature_range(self) -> tuple[int, int]: + """Return allowed temperature range.""" + return self.minimum_target_temperature, self.maximum_target_temperature + + @property + def minimum_target_temperature(self) -> int: + """Minimum available target temperature.""" + return self._device.sys_info["min_control_temp"] + + @property + def maximum_target_temperature(self) -> int: + """Minimum available target temperature.""" + return self._device.sys_info["max_control_temp"] + + @property + def target_temperature(self) -> float: + """Return target temperature.""" + return self._device.sys_info["target_temp"] + + @property + def states(self) -> set: + """Return thermostat states.""" + return set(self._device.sys_info["trv_states"]) + + async def set_target_temperature(self, target: float) -> dict: + """Set target temperature.""" + if ( + target < self.minimum_target_temperature + or target > self.maximum_target_temperature + ): + raise ValueError( + f"Invalid target temperature {target}, must be in range " + f"[{self.minimum_target_temperature},{self.maximum_target_temperature}]" + ) + + payload = {"target_temp": target} + # If the device has frost protection, we set it off to enable heating + if "frost_protection_on" in self._device.sys_info: + payload["frost_protection_on"] = False + + return await self.call("set_device_info", payload) + + @property + def temperature_offset(self) -> int: + """Return temperature offset.""" + return self._device.sys_info["temp_offset"] + + async def set_temperature_offset(self, offset: int) -> dict: + """Set temperature offset.""" + if offset < -10 or offset > 10: + raise ValueError("Temperature offset must be [-10, 10]") + + return await self.call("set_device_info", {"temp_offset": offset}) diff --git a/kasa/smart/modules/temperaturesensor.py b/kasa/smart/modules/temperaturesensor.py new file mode 100644 index 000000000..0a591a3d4 --- /dev/null +++ b/kasa/smart/modules/temperaturesensor.py @@ -0,0 +1,81 @@ +"""Implementation of temperature module.""" + +from __future__ import annotations + +from typing import Literal + +from ...feature import Feature +from ..smartmodule import SmartModule + + +class TemperatureSensor(SmartModule): + """Implementation of temperature module.""" + + REQUIRED_COMPONENT = "temperature" + QUERY_GETTER_NAME = "get_comfort_temp_config" + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + self._device, + id="temperature", + name="Temperature", + container=self, + attribute_getter="temperature", + icon="mdi:thermometer", + category=Feature.Category.Primary, + unit_getter="temperature_unit", + type=Feature.Type.Sensor, + ) + ) + if "current_temp_exception" in self._device.sys_info: + self._add_feature( + Feature( + self._device, + id="temperature_warning", + name="Temperature warning", + container=self, + attribute_getter="temperature_warning", + type=Feature.Type.BinarySensor, + icon="mdi:alert", + category=Feature.Category.Debug, + ) + ) + self._add_feature( + Feature( + self._device, + id="temperature_unit", + name="Temperature unit", + container=self, + attribute_getter="temperature_unit", + attribute_setter="set_temperature_unit", + type=Feature.Type.Choice, + choices_getter=lambda: ["celsius", "fahrenheit"], + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + + @property + def temperature(self) -> float: + """Return current humidity in percentage.""" + return self._device.sys_info["current_temp"] + + @property + def temperature_warning(self) -> bool: + """Return True if temperature is outside of the wanted range.""" + return self._device.sys_info.get("current_temp_exception", 0) != 0 + + @property + def temperature_unit(self) -> Literal["celsius", "fahrenheit"]: + """Return current temperature unit.""" + return self._device.sys_info["temp_unit"] + + async def set_temperature_unit( + self, unit: Literal["celsius", "fahrenheit"] + ) -> dict: + """Set the device temperature unit.""" + return await self.call("set_temperature_unit", {"temp_unit": unit}) diff --git a/kasa/smart/modules/thermostat.py b/kasa/smart/modules/thermostat.py new file mode 100644 index 000000000..74aad4be1 --- /dev/null +++ b/kasa/smart/modules/thermostat.py @@ -0,0 +1,74 @@ +"""Module for a Thermostat.""" + +from __future__ import annotations + +from typing import Annotated, Literal + +from ...feature import Feature +from ...interfaces.thermostat import Thermostat as ThermostatInterface +from ...interfaces.thermostat import ThermostatState +from ...module import FeatureAttribute, Module +from ..smartmodule import SmartModule + + +class Thermostat(SmartModule, ThermostatInterface): + """Implementation of a Thermostat.""" + + @property + def _all_features(self) -> dict[str, Feature]: + """Get the features for this module and any sub modules.""" + ret: dict[str, Feature] = {} + if temp_control := self._device.modules.get(Module.TemperatureControl): + ret.update(**temp_control._module_features) + if temp_sensor := self._device.modules.get(Module.TemperatureSensor): + ret.update(**temp_sensor._module_features) + return ret + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + + @property + def state(self) -> bool: + """Return thermostat state.""" + return self._device.modules[Module.TemperatureControl].state + + async def set_state(self, enabled: bool) -> dict: + """Set thermostat state.""" + return await self._device.modules[Module.TemperatureControl].set_state(enabled) + + @property + def mode(self) -> ThermostatState: + """Return thermostat state.""" + return self._device.modules[Module.TemperatureControl].mode + + @property + def target_temperature(self) -> Annotated[float, FeatureAttribute()]: + """Return target temperature.""" + return self._device.modules[Module.TemperatureControl].target_temperature + + async def set_target_temperature( + self, target: float + ) -> Annotated[dict, FeatureAttribute()]: + """Set target temperature.""" + return await self._device.modules[ + Module.TemperatureControl + ].set_target_temperature(target) + + @property + def temperature(self) -> Annotated[float, FeatureAttribute()]: + """Return current humidity in percentage.""" + return self._device.modules[Module.TemperatureSensor].temperature + + @property + def temperature_unit(self) -> Literal["celsius", "fahrenheit"]: + """Return current temperature unit.""" + return self._device.modules[Module.TemperatureSensor].temperature_unit + + async def set_temperature_unit( + self, unit: Literal["celsius", "fahrenheit"] + ) -> dict: + """Set the device temperature unit.""" + return await self._device.modules[ + Module.TemperatureSensor + ].set_temperature_unit(unit) diff --git a/kasa/smart/modules/time.py b/kasa/smart/modules/time.py new file mode 100644 index 000000000..f986fa34f --- /dev/null +++ b/kasa/smart/modules/time.py @@ -0,0 +1,93 @@ +"""Implementation of time module.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta, timezone, tzinfo +from typing import cast +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from ...cachedzoneinfo import CachedZoneInfo +from ...feature import Feature +from ...interfaces import Time as TimeInterface +from ..smartmodule import SmartModule + + +class Time(SmartModule, TimeInterface): + """Implementation of device_local_time.""" + + REQUIRED_COMPONENT = "time" + QUERY_GETTER_NAME = "get_device_time" + + _timezone: tzinfo = UTC + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + device=self._device, + id="device_time", + name="Device time", + attribute_getter="time", + container=self, + category=Feature.Category.Debug, + type=Feature.Type.Sensor, + ) + ) + + async def _post_update_hook(self) -> None: + """Perform actions after a device update.""" + td = timedelta(minutes=cast(float, self.data.get("time_diff"))) + if region := self.data.get("region"): + try: + # Zoneinfo will return a DST aware object + tz: tzinfo = await CachedZoneInfo.get_cached_zone_info(region) + except ZoneInfoNotFoundError: + tz = timezone(td, region) + else: + # in case the device returns a blank region this will result in the + # tzname being a UTC offset + tz = timezone(td) + self._timezone = tz + + @property + def timezone(self) -> tzinfo: + """Return current timezone.""" + return self._timezone + + @property + def time(self) -> datetime: + """Return device's current datetime.""" + return datetime.fromtimestamp( + cast(float, self.data.get("timestamp")), + tz=self.timezone, + ) + + async def set_time(self, dt: datetime) -> dict: + """Set device time.""" + if not dt.tzinfo: + timestamp = dt.replace(tzinfo=self.timezone).timestamp() + utc_offset = cast(timedelta, self.timezone.utcoffset(dt)) + else: + timestamp = dt.timestamp() + utc_offset = cast(timedelta, dt.utcoffset()) + time_diff = utc_offset / timedelta(minutes=1) + + params: dict[str, int | str] = { + "timestamp": int(timestamp), + "time_diff": int(time_diff), + } + if tz := dt.tzinfo: + region = tz.key if isinstance(tz, ZoneInfo) else dt.tzname() + # tzname can return null if a simple timezone object is provided. + if region: + params["region"] = region + return await self.call("set_device_time", params) + + async def _check_supported(self) -> bool: + """Additional check to see if the module is supported by the device. + + Hub attached sensors report the time module but do return device time. + """ + if self._device._is_hub_child: + return False + return await super()._check_supported() diff --git a/kasa/smart/modules/triggerlogs.py b/kasa/smart/modules/triggerlogs.py new file mode 100644 index 000000000..be63ff698 --- /dev/null +++ b/kasa/smart/modules/triggerlogs.py @@ -0,0 +1,37 @@ +"""Implementation of trigger logs module.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Annotated + +from mashumaro import DataClassDictMixin +from mashumaro.types import Alias + +from ..smartmodule import SmartModule + + +@dataclass +class LogEntry(DataClassDictMixin): + """Presentation of a single log entry.""" + + id: int + event_id: Annotated[str, Alias("eventId")] + timestamp: int + event: str + + +class TriggerLogs(SmartModule): + """Implementation of trigger logs.""" + + REQUIRED_COMPONENT = "trigger_log" + MINIMUM_UPDATE_INTERVAL_SECS = 60 * 60 + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {"get_trigger_logs": {"start_id": 0}} + + @property + def logs(self) -> list[LogEntry]: + """Return logs.""" + return [LogEntry.from_dict(log) for log in self.data["logs"]] diff --git a/kasa/smart/modules/waterleaksensor.py b/kasa/smart/modules/waterleaksensor.py new file mode 100644 index 000000000..fbf9e4040 --- /dev/null +++ b/kasa/smart/modules/waterleaksensor.py @@ -0,0 +1,101 @@ +"""Implementation of waterleak module.""" + +from __future__ import annotations + +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 + + +class WaterleakStatus(Enum): + """Waterleawk status.""" + + Normal = "normal" + LeakDetected = "water_leak" + Drying = "water_dry" + + +class WaterleakSensor(SmartModule): + """Implementation of waterleak module.""" + + REQUIRED_COMPONENT = "sensor_alarm" + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + self._device, + id="water_leak", + name="Water leak", + container=self, + attribute_getter="status", + icon="mdi:water", + category=Feature.Category.Debug, + type=Feature.Type.Sensor, + ) + ) + self._add_feature( + Feature( + self._device, + id="water_alert", + name="Water alert", + container=self, + attribute_getter="alert", + icon="mdi:water-alert", + category=Feature.Category.Primary, + type=Feature.Type.BinarySensor, + ) + ) + self._add_feature( + Feature( + self._device, + id="water_alert_timestamp", + name="Last alert timestamp", + container=self, + attribute_getter="alert_timestamp", + icon="mdi:alert", + category=Feature.Category.Info, + type=Feature.Type.Sensor, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + # Water leak information is contained in the main device info response. + return {} + + @property + def status(self) -> WaterleakStatus: + """Return current humidity in percentage.""" + return WaterleakStatus(self._device.sys_info["water_leak_status"]) + + @property + 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.""" + # The key is not always be there, maybe if it hasn't ever been triggered? + if "trigger_timestamp" not in self._device.sys_info: + return None + + ts = self._device.sys_info["trigger_timestamp"] + tz = self._time_module.timezone + return datetime.fromtimestamp(ts, tz=tz) diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py new file mode 100644 index 000000000..3f730f0e6 --- /dev/null +++ b/kasa/smart/smartchilddevice.py @@ -0,0 +1,167 @@ +"""Child device implementation.""" + +from __future__ import annotations + +import logging +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 ComponentsRaw, SmartDevice +from .smartmodule import SmartModule + +_LOGGER = logging.getLogger(__name__) + + +class SmartChildDevice(SmartDevice): + """Presentation of a child device. + + This wraps the protocol communications and sets internal data for the child. + """ + + CHILD_DEVICE_TYPE_MAP = { + "plug.powerstrip.sub-plug": DeviceType.Plug, + "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, + "subg.trigger.motion-sensor": DeviceType.Sensor, + "kasa.switch.outlet.sub-fan": DeviceType.Fan, + "kasa.switch.outlet.sub-dimmer": DeviceType.Dimmer, + "subg.trv": DeviceType.Thermostat, + "subg.trigger.button": DeviceType.Sensor, + } + + def __init__( + self, + parent: SmartDevice, + info: dict, + component_info_raw: ComponentsRaw, + *, + config: DeviceConfig | None = None, + protocol: SmartProtocol | None = None, + ) -> None: + self._id = info["device_id"] + _protocol = protocol or _ChildProtocolWrapper(self._id, parent.protocol) + super().__init__(parent.host, config=parent.config, protocol=_protocol) + self._parent = parent + self._update_internal_state(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. + + The parent updates our internal info so just update modules with + their own queries. + """ + await self._update(update_children) + + async def _update(self, update_children: bool = True) -> None: + """Update child module info. + + Internal implementation to allow patching of public update in the cli + or test framework. + """ + now = time.monotonic() + module_queries: list[SmartModule] = [] + req: dict[str, Any] = {} + for module in self.modules.values(): + 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: + 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( + module, now, had_query=module in module_queries + ) + 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_raw: ComponentsRaw, + protocol: SmartProtocol | None = None, + *, + last_update: dict | None = None, + ) -> SmartDevice: + """Create a child device based on device info and component listing. + + If creating a smart child from a different protocol, i.e. a camera hub, + protocol: SmartProtocol and last_update should be provided as per the + FIRST_UPDATE_MODULES expected by the update cycle as these cannot be + derived from the parent. + """ + child: SmartChildDevice = cls( + parent, child_info, child_components_raw, protocol=protocol + ) + if last_update: + child._last_update = last_update + await child._initialize_modules() + return child + + @property + def device_type(self) -> DeviceType: + """Return child device type.""" + if self._device_type is not DeviceType.Unknown: + return self._device_type + + if self.sys_info and (category := self.sys_info.get("category")): + dev_type = self.CHILD_DEVICE_TYPE_MAP.get(category) + if dev_type is None: + _LOGGER.warning( + "Unknown child device type %s for model %s, please open issue", + category, + self.model, + ) + self._device_type = DeviceType.Unknown + else: + self._device_type = dev_type + + return self._device_type + + def __repr__(self) -> str: + if not self._parent: + return f"<{self.device_type}(child) without parent>" + if not self._parent._last_update: + return f"<{self.device_type}(child) of {self._parent}>" + return f"<{self.device_type} {self.alias} ({self.model}) of {self._parent}>" diff --git a/kasa/smart/smartdevice.py b/kasa/smart/smartdevice.py new file mode 100644 index 000000000..6be2392ce --- /dev/null +++ b/kasa/smart/smartdevice.py @@ -0,0 +1,938 @@ +"""Module for a SMART device.""" + +from __future__ import annotations + +import base64 +import logging +import time +from collections import OrderedDict +from collections.abc import Sequence +from datetime import UTC, datetime, timedelta, tzinfo +from typing import TYPE_CHECKING, Any, TypeAlias, cast + +from ..device import Device, DeviceInfo, WifiNetwork +from ..device_type import DeviceType +from ..deviceconfig import DeviceConfig +from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode +from ..feature import Feature +from ..module import Module +from ..modulemapping import ModuleMapping, ModuleName +from ..protocols import SmartProtocol +from ..transports import AesTransport +from .modules import ( + ChildDevice, + Cloud, + DeviceModule, + Firmware, + Light, + Thermostat, + Time, +) +from .smartmodule import SmartModule + +if TYPE_CHECKING: + from .smartchilddevice import SmartChildDevice +_LOGGER = logging.getLogger(__name__) + + +# List of modules that non hub devices with children, i.e. ks240/P300, report on +# the child but only work on the parent. See longer note below in _initialize_modules. +# This list should be updated when creating new modules that could have the +# 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. +class SmartDevice(Device): + """Base class to represent a SMART protocol based device.""" + + # Modules that are called as part of the init procedure on first update + FIRST_UPDATE_MODULES = {DeviceModule, ChildDevice, Cloud} + + def __init__( + self, + host: str, + *, + config: DeviceConfig | None = None, + protocol: SmartProtocol | None = None, + ) -> None: + _protocol = protocol or SmartProtocol( + transport=AesTransport(config=config or DeviceConfig(host=host)), + ) + super().__init__(host=host, config=config, protocol=_protocol) + self.protocol: SmartProtocol + self._components_raw: ComponentsRaw | None = None + self._components: dict[str, int] = {} + self._state_information: dict[str, Any] = {} + self._modules: OrderedDict[str | ModuleName[Module], SmartModule] = ( + OrderedDict() + ) + self._parent: SmartDevice | None = None + 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.""" + child_info_query = { + "get_child_device_component_list": None, + "get_child_device_list": None, + } + resp = await self.protocol.query(child_info_query) + self.internal_state.update(resp) + + async def _try_create_child( + self, info: dict, child_components: dict + ) -> SmartDevice | None: + from .smartchilddevice import SmartChildDevice + + 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]: + """Return list of children.""" + return list(self._children.values()) + + @property + def modules(self) -> ModuleMapping[SmartModule]: + """Return the device modules.""" + return cast(ModuleMapping[SmartModule], self._modules) + + def _try_get_response( + self, responses: dict, request: str, default: Any | None = None + ) -> dict: + response = responses.get(request) + if isinstance(response, SmartErrorCode): + _LOGGER.debug( + "Error %s getting request %s for device %s", + response, + request, + self.host, + ) + response = None + if response is not None: + return response + if default is not None: + return default + raise KasaException( + 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. + + We fetch the device info and the available components as early as possible. + If the device reports supporting child devices, they are also initialized. + """ + initial_query = { + "component_nego": None, + "get_device_info": None, + "get_connect_cloud_state": None, + } + resp = await self.protocol.query(initial_query) + + # Save the initial state to allow modules access the device info already + # during the initialization, which is necessary as some information like the + # supported color temperature range is contained within the response. + self._last_update.update(resp) + self._info = self._try_get_response(resp, "get_device_info") + + # Create our internal presentation of available components + self._components_raw = cast(ComponentsRaw, resp["component_nego"]) + + self._components = self._parse_components(self._components_raw) + + if "child_device" in self._components and not self.children: + await self._initialize_children() + + 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.get("device_id") + if child_id not in self._children: + # _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 = True) -> None: + """Update the device.""" + if self.credentials is None and self.credentials_hash is None: + raise AuthenticationError("Tapo plug requires authentication.") + + first_update = self._last_update_time is None + now = time.monotonic() + self._last_update_time = now + + if first_update: + await self._negotiate() + await self._initialize_modules() + # Run post update for the cloud module + if cloud_mod := self.modules.get(Module.Cloud): + await self._handle_module_post_update(cloud_mod, now, had_query=True) + + resp = await self._modular_update(first_update, now) + + 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 children_changed or update_children or self.device_type != DeviceType.Hub: + for child in self._children.values(): + if TYPE_CHECKING: + assert isinstance(child, SmartChildDevice) + await child._update() + + # 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() + + if _LOGGER.isEnabledFor(logging.DEBUG): + updated = self._last_update if first_update else resp + _LOGGER.debug("Update completed %s: %s", self.host, list(updated.keys())) + + async def _handle_module_post_update( + self, module: SmartModule, update_time: float, had_query: bool + ) -> None: + if module.disabled: + return # pragma: no cover + if had_query: + module._last_update_time = update_time + try: + await module._post_update_hook() + module._set_error(None) + except Exception as ex: + # Only set the error if a query happened. + if had_query: + module._set_error(ex) + _LOGGER.warning( + "Error processing %s for device %s, module will be unavailable: %s", + module.name, + self.host, + ex, + ) + + async def _modular_update( + self, first_update: bool, update_time: float + ) -> dict[str, Any]: + """Update the device with via the module queries.""" + req: dict[str, Any] = {} + # Keep a track of actual module queries so we can track the time for + # modules that do not need to be updated frequently + module_queries: list[SmartModule] = [] + mq = { + module: query + for module in self._modules.values() + if (first_update or module.disabled is False) and (query := module.query()) + } + for module, query in mq.items(): + if first_update and module.__class__ in self.FIRST_UPDATE_MODULES: + module._last_update_time = update_time + continue + if module._should_update(update_time): + module_queries.append(module) + req.update(query) + + _LOGGER.debug( + "Querying %s for modules: %s", + self.host, + ", ".join(mod.name for mod in module_queries), + ) + + 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 + ) + + info_resp = self._last_update if first_update else resp + self._last_update.update(**resp) + self._update_internal_info(info_resp) + + # Call handle update for modules that want to update internal data + for module in self._modules.values(): + await self._handle_module_post_update( + module, update_time, had_query=module in module_queries + ) + + return resp + + async def _handle_modular_update_error( + self, + ex: Exception, + first_update: bool, + module_names: str, + requests: dict[str, Any], + ) -> dict[str, Any]: + """Handle an error on calling module update. + + Will try to call all modules individually + and any errors such as timeouts will be set as a SmartErrorCode. + """ + msg_part = "on first update" if first_update else "after first update" + + _LOGGER.debug( + "Error querying %s for modules '%s' %s: %s", + self.host, + module_names, + msg_part, + ex, + ) + responses = {} + for meth, params in requests.items(): + try: + resp = await self.protocol.query({meth: params}) + responses[meth] = resp[meth] + except Exception as iex: + _LOGGER.debug( + "Error querying %s individually for module query '%s' %s: %s", + self.host, + meth, + msg_part, + iex, + ) + responses[meth] = SmartErrorCode.INTERNAL_QUERY_ERROR + return responses + + async def _initialize_modules(self) -> None: + """Initialize modules based on component negotiation response.""" + from .smartmodule import SmartModule + + # Some wall switches (like ks240) are internally presented as having child + # devices which report the child's components on the parent's sysinfo, even + # when they need to be accessed through the children. + # The logic below ensures that such devices add all but whitelisted, only on + # the child device. + # It also ensures that devices like power strips do not add modules such as + # firmware to the child devices. + skip_parent_only_modules = False + child_modules_to_skip: dict = {} # TODO: this is never non-empty + if self._parent and self._parent.device_type != DeviceType.Hub: + skip_parent_only_modules = True + + for mod in SmartModule.REGISTERED_MODULES.values(): + if ( + skip_parent_only_modules and mod in NON_HUB_PARENT_ONLY_MODULES + ) or mod.__name__ in child_modules_to_skip: + continue + required_component = cast(str, mod.REQUIRED_COMPONENT) + 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.", + self.host, + required_component, + mod.__name__, + ) + module = mod(self, required_component) + if await module._check_supported(): + self._modules[module.name] = module + + if ( + Module.Brightness in self._modules + or Module.Color in self._modules + or Module.ColorTemperature in self._modules + ): + self._modules[Light.__name__] = Light(self, "light") + if ( + Module.TemperatureControl in self._modules + and Module.TemperatureSensor in self._modules + ): + 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( + Feature( + self, + id="device_id", + name="Device ID", + attribute_getter="device_id", + category=Feature.Category.Debug, + type=Feature.Type.Sensor, + ) + ) + if "device_on" in self._info: + self._add_feature( + Feature( + self, + id="state", + name="State", + attribute_getter="is_on", + attribute_setter="set_state", + type=Feature.Type.Switch, + category=Feature.Category.Primary, + ) + ) + + if "signal_level" in self._info: + self._add_feature( + Feature( + self, + id="signal_level", + name="Signal Level", + attribute_getter=lambda x: x._info["signal_level"], + icon="mdi:signal", + category=Feature.Category.Info, + type=Feature.Type.Sensor, + ) + ) + + if "rssi" in self._info: + self._add_feature( + Feature( + self, + id="rssi", + name="RSSI", + attribute_getter=lambda x: x._info["rssi"], + icon="mdi:signal", + unit_getter=lambda: "dBm", + category=Feature.Category.Debug, + type=Feature.Type.Sensor, + ) + ) + + if "ssid" in self._info: + self._add_feature( + Feature( + device=self, + id="ssid", + name="SSID", + attribute_getter="ssid", + icon="mdi:wifi", + category=Feature.Category.Debug, + type=Feature.Type.Sensor, + ) + ) + + # 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: + self._add_feature( + Feature( + device=self, + id="on_since", + name="On since", + attribute_getter="on_since", + icon="mdi:clock", + category=Feature.Category.Debug, + type=Feature.Type.Sensor, + ) + ) + + self._add_feature( + Feature( + device=self, + id="reboot", + name="Reboot", + attribute_setter="reboot", + icon="mdi:restart", + category=Feature.Category.Debug, + type=Feature.Type.Action, + ) + ) + + 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) + + @property + def _is_hub_child(self) -> bool: + """Returns true if the device is a child of a hub.""" + return self.parent is not None and self.parent.device_type is DeviceType.Hub + + @property + def is_cloud_connected(self) -> bool: + """Returns if the device is connected to the cloud.""" + if Module.Cloud not in self.modules: + return False + return self.modules[Module.Cloud].is_connected + + @property + def sys_info(self) -> dict[str, Any]: + """Returns the device info.""" + return self._info # type: ignore + + @property + def model(self) -> str: + """Returns the device model.""" + # If update hasn't been called self._device_info can't be used + if self._last_update: + return self.device_info.short_name + + disco_model = str(self._info.get("device_model")) + long_name, _, _ = disco_model.partition("(") + return long_name + + @property + def alias(self) -> str | None: + """Returns the device alias or nickname.""" + if self._info and (nickname := self._info.get("nickname")): + return base64.b64decode(nickname).decode() + else: + return None + + @property + def time(self) -> datetime: + """Return the time.""" + if (time_mod := self.modules.get(Module.Time)) or ( + self._parent and (time_mod := self._parent.modules.get(Module.Time)) + ): + return time_mod.time + + # We have no device time, use current local time. + return datetime.now(UTC).astimezone().replace(microsecond=0) + + @property + def on_since(self) -> datetime | None: + """Return the time that the device was turned on or None if turned off. + + This returns a cached value if the device reported value difference is under + five seconds to avoid device-caused jitter. + """ + if ( + not self._info.get("device_on") + or (on_time := self._info.get("on_time")) is None + ): + self._on_since = None + return None + + on_time = cast(float, on_time) + on_since = self.time - timedelta(seconds=on_time) + if not self._on_since or timedelta( + seconds=0 + ) < on_since - self._on_since > timedelta(seconds=5): + self._on_since = on_since + return self._on_since + + @property + def timezone(self) -> tzinfo: + """Return the timezone and time_difference.""" + if TYPE_CHECKING: + assert self.time.tzinfo + return self.time.tzinfo + + @property + def hw_info(self) -> dict: + """Return hardware info for the device.""" + return { + "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("device_id"), + "dev_name": self.alias, + "oemId": self._info.get("oem_id"), + } + + @property + def location(self) -> dict: + """Return the device location.""" + loc = { + "latitude": cast(float, self._info.get("latitude", 0)) / 10_000, + "longitude": cast(float, self._info.get("longitude", 0)) / 10_000, + } + return loc + + @property + def rssi(self) -> int | None: + """Return the rssi.""" + rssi = self._info.get("rssi") + return int(rssi) if rssi else None + + @property + def mac(self) -> str: + """Return the mac formatted with colons.""" + return str(self._info.get("mac")).replace("-", ":") + + @property + def device_id(self) -> str: + """Return the device id.""" + return str(self._info.get("device_id")) + + @property + def internal_state(self) -> dict: + """Return all the internal state data.""" + return self._last_update + + 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 = info + + async def _query_helper(self, method: str, params: dict | None = None) -> dict: + return await self.protocol.query({method: params}) + + @property + def ssid(self) -> str: + """Return ssid of the connected wifi ap.""" + ssid = self._info.get("ssid") + ssid = base64.b64decode(ssid).decode() if ssid else "No SSID" + return ssid + + @property + def has_emeter(self) -> bool: + """Return if the device has emeter.""" + return Module.Energy in self.modules + + @property + def is_on(self) -> bool: + """Return true if the device is on.""" + return bool(self._info.get("device_on")) + + async def set_state(self, on: bool) -> dict: + """Set the device state. + + See :meth:`is_on`. + """ + return await self.protocol.query({"set_device_info": {"device_on": on}}) + + async def turn_on(self, **kwargs: Any) -> dict: + """Turn on the device.""" + return await self.set_state(True) + + async def turn_off(self, **kwargs: Any) -> dict: + """Turn off the device.""" + return await self.set_state(False) + + def update_from_discover_info( + self, + info: dict, + ) -> None: + """Update state from info from the discover call.""" + self._discovery_info = info + self._info = info + + async def wifi_scan(self) -> list[WifiNetwork]: + """Scan for available wifi networks.""" + + def _net_for_scan_info(res: dict) -> WifiNetwork: + return WifiNetwork( + ssid=base64.b64decode(res["ssid"]).decode(), + cipher_type=res["cipher_type"], + key_type=res["key_type"], + channel=res["channel"], + signal_level=res["signal_level"], + bssid=res["bssid"], + ) + + _LOGGER.debug("Querying networks") + + resp = await self.protocol.query({"get_wireless_scan_info": {"start_index": 0}}) + networks = [ + _net_for_scan_info(net) for net in resp["get_wireless_scan_info"]["ap_list"] + ] + return 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 keytype: + raise KasaException("KeyType is required for this device.") + + payload = { + "account": { + "username": base64.b64encode( + self.credentials.username.encode() + ).decode(), + "password": base64.b64encode( + self.credentials.password.encode() + ).decode(), + }, + "wireless": { + "key_type": keytype, + "password": base64.b64encode(password.encode()).decode(), + "ssid": base64.b64encode(ssid.encode()).decode(), + }, + "time": self.internal_state["get_device_time"], + } + + # 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({"set_qs_info": 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 {} + + async def update_credentials(self, username: str, password: str) -> dict: + """Update device credentials. + + This will replace the existing authentication credentials on the device. + """ + time_data = self.internal_state["get_device_time"] + payload = { + "account": { + "username": base64.b64encode(username.encode()).decode(), + "password": base64.b64encode(password.encode()).decode(), + }, + "time": time_data, + } + return await self.protocol.query({"set_qs_info": payload}) + + async def set_alias(self, alias: str) -> dict: + """Set the device name (alias).""" + return await self.protocol.query( + {"set_device_info": {"nickname": base64.b64encode(alias.encode()).decode()}} + ) + + async def reboot(self, delay: int = 1) -> None: + """Reboot the device. + + Note that giving a delay of zero causes this to block, + as the device reboots immediately without responding to the call. + """ + await self.protocol.query({"device_reboot": {"delay": delay}}) + + async def factory_reset(self) -> None: + """Reset device back to factory settings. + + Note, this does not downgrade the firmware. + """ + await self.protocol.query("device_reset") + + @property + def device_type(self) -> DeviceType: + """Return the device type.""" + if self._device_type is not DeviceType.Unknown: + return self._device_type + + 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( + list(self._components.keys()), type_str + ) + + return self._device_type + + @staticmethod + def _get_device_type_from_components( + components: list[str], device_type: str + ) -> DeviceType: + """Find type to be displayed as a supported device category.""" + if "HUB" in device_type: + return DeviceType.Hub + if "PLUG" in device_type: + if "child_device" in components: + return DeviceType.Strip + return DeviceType.Plug + if "light_strip" in components: + return DeviceType.LightStrip + if "SWITCH" in device_type and "child_device" in components: + return DeviceType.WallSwitch + if "dimmer_calibration" in components: + return DeviceType.Dimmer + if "brightness" in components: + return DeviceType.Bulb + if "SWITCH" in device_type: + return DeviceType.WallSwitch + if "SENSOR" in device_type: + 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: + """Get model information for a device.""" + di = info["get_device_info"] + components = [comp["id"] for comp in info["component_nego"]["component_list"]] + + # Get model/region info + short_name = di["model"] + region = None + if discovery_info: + device_model = discovery_info["device_model"] + long_name, _, region = device_model.partition("(") + if region: # P100 doesn't have region + region = region.replace(")", "") + else: + long_name = short_name + if not region: # some devices have region in specs + region = di.get("specs") + + # Get other info + device_family = di["type"] + device_type = SmartDevice._get_device_type_from_components( + components, device_family + ) + fw_version_full = di["fw_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 + _protocol, devicetype = device_family.split(".") + # Brand inferred from SMART.KASAPLUG/SMART.TAPOPLUG etc. + brand = devicetype[:4].lower() + + return DeviceInfo( + short_name=short_name, + long_name=long_name, + brand=brand, + device_family=device_family, + device_type=device_type, + hardware_version=di["hw_ver"], + firmware_version=firmware_version, + firmware_build=firmware_build, + requires_auth=True, + region=region, + ) diff --git a/kasa/smart/smartmodule.py b/kasa/smart/smartmodule.py new file mode 100644 index 000000000..91efa33dc --- /dev/null +++ b/kasa/smart/smartmodule.py @@ -0,0 +1,263 @@ +"""Base implementation for SMART modules.""" + +from __future__ import annotations + +import logging +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 +from ..module import Module + +if TYPE_CHECKING: + from .smartdevice import SmartDevice + +_LOGGER = logging.getLogger(__name__) + +_T = TypeVar("_T", bound="SmartModule") +_P = ParamSpec("_P") +_R = TypeVar("_R") + + +def allow_update_after( + 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. + """ + + @wraps(func) + async def _async_wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> _R: + try: + return await func(self, *args, **kwargs) + finally: + self._last_update_time = None + + return _async_wrap + + +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 + return func(self) + + return _wrap + + +class SmartModule(Module): + """Base class for SMART modules.""" + + NAME: str + #: Module is initialized, if the given component is available + REQUIRED_COMPONENT: 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 = "" + + 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 + + def __init__(self, device: SmartDevice, module: str) -> None: + self._device: SmartDevice + super().__init__(device, module) + 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 + # other classes can inherit from smartmodule and not be registered + if cls.__module__.split(".")[-2] == "modules": + _LOGGER.debug("Registering %s", cls) + cls.REGISTERED_MODULES[cls._module_name()] = cls + + def _set_error(self, err: Exception | None) -> None: + if err is None: + self._error_count = 0 + self._last_update_error = None + else: + self._last_update_error = KasaException("Module update error", err) + self._error_count += 1 + if self._error_count == self.DISABLE_AFTER_ERROR_COUNT: + _LOGGER.error( + "Error processing %s for device %s, module will be disabled: %s", + self.name, + self._device.host, + err, + ) + if self._error_count > self.DISABLE_AFTER_ERROR_COUNT: + _LOGGER.error( # pragma: no cover + "Unexpected error processing %s for device %s, " + "module should be disabled: %s", + self.name, + self._device.host, + err, + ) + + @property + def update_interval(self) -> int: + """Time to wait between updates.""" + 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.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__) + + @property + def name(self) -> str: + """Name of the module.""" + return self._module_name() + + async def _post_update_hook(self) -> None: # noqa: B027 + """Perform actions after a device update. + + Any modules overriding this should ensure that self.data is + accessed unless the module should remain active despite errors. + """ + assert self.data # noqa: S101 + + def query(self) -> dict: + """Query to execute during the update cycle. + + Default implementation uses the raw query getter w/o parameters. + """ + 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. + + Just a helper method. + """ + 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. + + If the module performs only a single query, the resulting response is unwrapped. + If the module does not define a query, this property returns a reference + to the main "get_device_info" response. + """ + dev = self._device + q = self.query() + + if not q: + return dev.sys_info + + q_keys = list(q.keys()) + query_key = q_keys[0] + + # TODO: hacky way to check if update has been called. + # The way this falls back to parent may not always be wanted. + # Especially, devices can have their own firmware updates. + if query_key not in dev._last_update: + if dev._parent and query_key in dev._parent._last_update: + _LOGGER.debug("%s not found child, but found on parent", query_key) + dev = dev._parent + else: + raise KasaException( + f"You need to call update() prior accessing module data" + f" for '{self._module}'" + ) + + 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): + 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], + ) + + filtered_data.pop(key) + + if len(filtered_data) == 1 and not remove_keys: + return next(iter(filtered_data.values())) + + return filtered_data + + @property + def supported_version(self) -> int: + """Return version supported by the device. + + If the module has no required component, this will return -1. + """ + if self.REQUIRED_COMPONENT is not None: + return self._device._components[self.REQUIRED_COMPONENT] + return -1 + + async def _check_supported(self) -> bool: + """Additional check to see if the module is supported by the device. + + Used for parents who report components on the parent that are only available + on the child or for modules where the device has a pointless component like + color_temp_range but only supports one value. + """ + return True + + def _has_data_error(self) -> bool: + try: + assert self.data # noqa: S101 + return False + except DeviceError: + return True diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py deleted file mode 100644 index be81c1346..000000000 --- a/kasa/smartbulb.py +++ /dev/null @@ -1,360 +0,0 @@ -"""Module for bulbs (LB*, KL*, KB*).""" -import re -from typing import Any, Dict, Tuple, cast - -from kasa.smartdevice import ( - DeviceType, - SmartDevice, - SmartDeviceException, - requires_update, -) - -TPLINK_KELVIN = { - "LB130": (2500, 9000), - "LB120": (2700, 6500), - "LB230": (2500, 9000), - "KB130": (2500, 9000), - "KL130": (2500, 9000), - r"KL120\(EU\)": (2700, 6500), - r"KL120\(US\)": (2700, 5000), - r"KL430\(US\)": (2500, 9000), -} - - -class SmartBulb(SmartDevice): - """Representation of a TP-Link Smart Bulb. - - To initialize, you have to await :func:`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. - - Errors reported by the device are raised as :class:`SmartDeviceException`s, - and should be handled by the user of the library. - - Examples: - >>> import asyncio - >>> bulb = SmartBulb("127.0.0.1") - >>> asyncio.run(bulb.update()) - >>> print(bulb.alias) - KL130 office bulb - - Bulbs, like any other supported devices, can be turned on and off: - - >>> asyncio.run(bulb.turn_off()) - >>> asyncio.run(bulb.turn_on()) - >>> asyncio.run(bulb.update()) - >>> print(bulb.is_on) - True - - You can use the is_-prefixed properties to check for supported features - >>> bulb.is_dimmable - True - >>> bulb.is_color - True - >>> bulb.is_variable_color_temp - True - - All known bulbs support changing the brightness: - - >>> bulb.brightness - 30 - >>> asyncio.run(bulb.set_brightness(50)) - >>> asyncio.run(bulb.update()) - >>> bulb.brightness - 50 - - Bulbs supporting color temperature can be queried to know which range is accepted: - - >>> bulb.valid_temperature_range - (2500, 9000) - >>> asyncio.run(bulb.set_color_temp(3000)) - >>> asyncio.run(bulb.update()) - >>> bulb.color_temp - 3000 - - Color bulbs can be adjusted by passing hue, saturation and value: - - >>> asyncio.run(bulb.set_hsv(180, 100, 80)) - >>> asyncio.run(bulb.update()) - >>> bulb.hsv - (180, 100, 80) - - If you don't want to use the default transitions, you can pass `transition` in milliseconds. - This applies to all transitions (turn_on, turn_off, set_hsv, set_color_temp, set_brightness). - The following changes the brightness over a period of 10 seconds: - - >>> asyncio.run(bulb.set_brightness(100, transition=10_000)) - - """ - - LIGHT_SERVICE = "smartlife.iot.smartbulb.lightingservice" - SET_LIGHT_METHOD = "transition_light_state" - - def __init__(self, host: str) -> None: - super().__init__(host=host) - self.emeter_type = "smartlife.iot.common.emeter" - self._device_type = DeviceType.Bulb - - @property # type: ignore - @requires_update - def is_color(self) -> bool: - """Whether the bulb supports color changes.""" - sys_info = self.sys_info - return bool(sys_info["is_color"]) - - @property # type: ignore - @requires_update - def is_dimmable(self) -> bool: - """Whether the bulb supports brightness changes.""" - sys_info = self.sys_info - return bool(sys_info["is_dimmable"]) - - @property # type: ignore - @requires_update - def is_variable_color_temp(self) -> bool: - """Whether the bulb supports color temperature changes.""" - sys_info = self.sys_info - return bool(sys_info["is_variable_color_temp"]) - - @property # type: ignore - @requires_update - def valid_temperature_range(self) -> Tuple[int, int]: - """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 SmartDeviceException("Color temperature not supported") - for model, temp_range in TPLINK_KELVIN.items(): - sys_info = self.sys_info - if re.match(model, sys_info["model"]): - return temp_range - - raise SmartDeviceException( - "Unknown color temperature range, please open an issue on github" - ) - - @property # type: ignore - @requires_update - def light_state(self) -> Dict[str, str]: - """Query the light state.""" - light_state = self._last_update["system"]["get_sysinfo"]["light_state"] - if light_state is None: - raise SmartDeviceException( - "The device has no light_state or you have not called update()" - ) - - # if the bulb is off, its state is stored under a different key - # as is_on property depends on on_off itself, we check it here manually - is_on = light_state["on_off"] - if not is_on: - off_state = {**light_state["dft_on_state"], "on_off": is_on} - return cast(dict, off_state) - - return light_state - - async def get_light_details(self) -> Dict[str, int]: - """Return light details. - - Example: - {'lamp_beam_angle': 290, 'min_voltage': 220, 'max_voltage': 240, - 'wattage': 5, 'incandescent_equivalent': 40, 'max_lumens': 450, - 'color_rendering_index': 80} - """ - return await self._query_helper(self.LIGHT_SERVICE, "get_light_details") - - async def get_turn_on_behavior(self) -> Dict: - """Return the behavior for turning the bulb on. - - Example: - {'soft_on': {'mode': 'last_status'}, - 'hard_on': {'mode': 'last_status'}} - """ - return await self._query_helper(self.LIGHT_SERVICE, "get_default_behavior") - - async def get_light_state(self) -> Dict[str, Dict]: - """Query the light state.""" - # TODO: add warning and refer to use light.state? - return await self._query_helper(self.LIGHT_SERVICE, "get_light_state") - - async def set_light_state(self, state: Dict, *, transition: int = None) -> Dict: - """Set the light state.""" - if transition is not None: - state["transition_period"] = transition - - # if no on/off is defined, turn on the light - if "on_off" not in state: - state["on_off"] = 1 - - # This is necessary to allow turning on into a specific state - state["ignore_default"] = 1 - - light_state = await self._query_helper( - self.LIGHT_SERVICE, self.SET_LIGHT_METHOD, state - ) - return light_state - - @property # type: ignore - @requires_update - def hsv(self) -> Tuple[int, int, int]: - """Return the current HSV state of the bulb. - - :return: hue, saturation and value (degrees, %, %) - """ - if not self.is_color: - raise SmartDeviceException("Bulb does not support color.") - - light_state = cast(dict, self.light_state) - - hue = light_state["hue"] - saturation = light_state["saturation"] - value = light_state["brightness"] - - return hue, saturation, value - - def _raise_for_invalid_brightness(self, value): - if not isinstance(value, int) or not (0 <= value <= 100): - raise ValueError( - "Invalid brightness value: {} " "(valid range: 0-100%)".format(value) - ) - - @requires_update - async def set_hsv( - self, hue: int, saturation: int, value: int, *, transition: int = None - ) -> Dict: - """Set new HSV. - - :param int hue: hue in degrees - :param int saturation: saturation in percentage [0,100] - :param int value: value in percentage [0, 100] - :param int transition: transition in milliseconds. - """ - if not self.is_color: - raise SmartDeviceException("Bulb does not support color.") - - if not isinstance(hue, int) or not (0 <= hue <= 360): - raise ValueError( - "Invalid hue value: {} " "(valid range: 0-360)".format(hue) - ) - - if not isinstance(saturation, int) or not (0 <= saturation <= 100): - raise ValueError( - "Invalid saturation value: {} " - "(valid range: 0-100%)".format(saturation) - ) - - self._raise_for_invalid_brightness(value) - - light_state = { - "hue": hue, - "saturation": saturation, - "brightness": value, - "color_temp": 0, - } - - return await self.set_light_state(light_state, transition=transition) - - @property # type: ignore - @requires_update - def color_temp(self) -> int: - """Return color temperature of the device in kelvin.""" - if not self.is_variable_color_temp: - raise SmartDeviceException("Bulb does not support colortemp.") - - light_state = self.light_state - return int(light_state["color_temp"]) - - @requires_update - async def set_color_temp( - self, temp: int, *, brightness=None, transition: int = None - ) -> Dict: - """Set the color temperature of the device in kelvin. - - :param int temp: The new color temperature, in Kelvin - :param int transition: transition in milliseconds. - """ - if not self.is_variable_color_temp: - raise SmartDeviceException("Bulb does not support colortemp.") - - valid_temperature_range = self.valid_temperature_range - if temp < valid_temperature_range[0] or temp > valid_temperature_range[1]: - raise ValueError( - "Temperature should be between {} " - "and {}".format(*valid_temperature_range) - ) - - light_state = {"color_temp": temp} - if brightness is not None: - light_state["brightness"] = brightness - - return await self.set_light_state(light_state, transition=transition) - - @property # type: ignore - @requires_update - def brightness(self) -> int: - """Return the current brightness in percentage.""" - if not self.is_dimmable: # pragma: no cover - raise SmartDeviceException("Bulb is not dimmable.") - - light_state = self.light_state - return int(light_state["brightness"]) - - @requires_update - async def set_brightness(self, brightness: int, *, transition: int = None) -> Dict: - """Set the brightness in percentage. - - :param int brightness: brightness in percent - :param int transition: transition in milliseconds. - """ - if not self.is_dimmable: # pragma: no cover - raise SmartDeviceException("Bulb is not dimmable.") - - self._raise_for_invalid_brightness(brightness) - - light_state = {"brightness": brightness} - return await self.set_light_state(light_state, transition=transition) - - @property # type: ignore - @requires_update - def state_information(self) -> Dict[str, Any]: - """Return bulb-specific state information.""" - info: Dict[str, Any] = { - "Brightness": self.brightness, - "Is dimmable": self.is_dimmable, - } - if self.is_variable_color_temp: - info["Color temperature"] = self.color_temp - info["Valid temperature range"] = self.valid_temperature_range - if self.is_color: - info["HSV"] = self.hsv - - return info - - @property # type: ignore - @requires_update - def is_on(self) -> bool: - """Return whether the device is on.""" - light_state = self.light_state - return bool(light_state["on_off"]) - - async def turn_off(self, *, transition: int = None, **kwargs) -> Dict: - """Turn the bulb off. - - :param int transition: transition in milliseconds. - """ - return await self.set_light_state({"on_off": 0}, transition=transition) - - async def turn_on(self, *, transition: int = None, **kwargs) -> Dict: - """Turn the bulb on. - - :param int transition: transition in milliseconds. - """ - return await self.set_light_state({"on_off": 1}, transition=transition) - - @property # type: ignore - @requires_update - def has_emeter(self) -> bool: - """Return that the bulb has an emeter.""" - return True diff --git a/kasa/smartcam/__init__.py b/kasa/smartcam/__init__.py new file mode 100644 index 000000000..b16e5410b --- /dev/null +++ b/kasa/smartcam/__init__.py @@ -0,0 +1,7 @@ +"""Package for supporting tapo-branded cameras.""" + +from .detectionmodule import DetectionModule +from .smartcamchild import SmartCamChild +from .smartcamdevice import 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 new file mode 100644 index 000000000..3434bddbf --- /dev/null +++ b/kasa/smartcam/modules/__init__.py @@ -0,0 +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 new file mode 100644 index 000000000..df1891ecf --- /dev/null +++ b/kasa/smartcam/modules/alarm.py @@ -0,0 +1,216 @@ +"""Implementation of alarm module.""" + +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 +DURATION_MAX = 6000 + +VOLUME_MIN = 0 +VOLUME_MAX = 10 + + +class Alarm(SmartCamModule, AlarmInterface): + """Implementation of alarm module.""" + + REQUIRED_COMPONENT = "siren" + QUERY_GETTER_NAME = "getSirenStatus" + QUERY_MODULE_NAME = "siren" + + def query(self) -> dict: + """Query to execute during the update cycle.""" + q = super().query() + q["getSirenConfig"] = {self.QUERY_MODULE_NAME: {}} + q["getSirenTypeList"] = {self.QUERY_MODULE_NAME: {}} + + return q + + def _initialize_features(self) -> None: + """Initialize features.""" + device = self._device + self._add_feature( + Feature( + device, + id="alarm", + name="Alarm", + container=self, + attribute_getter="active", + icon="mdi:bell", + category=Feature.Category.Debug, + type=Feature.Type.BinarySensor, + ) + ) + self._add_feature( + Feature( + device, + id="alarm_sound", + name="Alarm sound", + container=self, + attribute_getter="alarm_sound", + attribute_setter="set_alarm_sound", + category=Feature.Category.Config, + type=Feature.Type.Choice, + choices_getter="alarm_sounds", + ) + ) + self._add_feature( + Feature( + device, + id="alarm_volume", + 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_MIN, VOLUME_MAX), + ) + ) + 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: (DURATION_MIN, DURATION_MAX), + ) + ) + self._add_feature( + Feature( + device, + id="test_alarm", + name="Test alarm", + container=self, + attribute_setter="play", + type=Feature.Type.Action, + ) + ) + self._add_feature( + Feature( + device, + id="stop_alarm", + name="Stop alarm", + container=self, + attribute_setter="stop", + type=Feature.Type.Action, + ) + ) + + @property + def alarm_sound(self) -> Annotated[str, FeatureAttribute()]: + """Return current alarm sound.""" + return self.data["getSirenConfig"]["siren_type"] + + @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. + """ + config = self._validate_and_get_config(sound=sound) + return await self.call("setSirenConfig", {"siren": config}) + + @property + def alarm_sounds(self) -> list[str]: + """Return list of available alarm sounds.""" + return self.data["getSirenTypeList"]["siren_type_list"] + + @property + 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"]) + + @allow_update_after + async def set_alarm_volume( + self, volume: int + ) -> Annotated[dict, FeatureAttribute()]: + """Set alarm volume.""" + config = self._validate_and_get_config(volume=volume) + return await self.call("setSirenConfig", {"siren": config}) + + @property + def alarm_duration(self) -> Annotated[int, FeatureAttribute()]: + """Return alarm duration.""" + return self.data["getSirenConfig"]["duration"] + + @allow_update_after + async def set_alarm_duration( + self, duration: int + ) -> Annotated[dict, FeatureAttribute()]: + """Set alarm volume.""" + 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, + *, + 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 new file mode 100644 index 000000000..bd4b28086 --- /dev/null +++ b/kasa/smartcam/modules/camera.py @@ -0,0 +1,129 @@ +"""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 ...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.""" + + REQUIRED_COMPONENT = "video" + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + 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 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 .""" + config = self._device.config + if credentials := config.credentials: + return credentials + + if credentials_hash := config.credentials_hash: + try: + decoded = json_loads( + base64.b64decode(credentials_hash.encode()).decode() + ) + except Exception: + _LOGGER.warning( + "Unable to deserialize credentials_hash: %s", credentials_hash + ) + return None + if (username := decoded.get("un")) and (password := decoded.get("pwd")): + return Credentials(username, password) + + return 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. + These could be different credentials to tplink cloud credentials. + If not provided will use tplink credentials if available + :return: rtsp url with escaped credentials or None if no credentials or + camera is off. + """ + if self._device._is_hub_child: + return None + + 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}@{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 new file mode 100644 index 000000000..812fd0c1b --- /dev/null +++ b/kasa/smartcam/modules/childdevice.py @@ -0,0 +1,29 @@ +"""Module for child devices.""" + +from ...device_type import DeviceType +from ..smartcammodule import SmartCamModule + + +class ChildDevice(SmartCamModule): + """Implementation for child devices.""" + + REQUIRED_COMPONENT = "childControl" + NAME = "childdevice" + QUERY_GETTER_NAME = "getChildDeviceList" + # This module is unusual in that QUERY_MODULE_NAME in the response is not + # the same one used in the request. + QUERY_MODULE_NAME = "child_device_list" + + def query(self) -> dict: + """Query to execute during the update cycle. + + Default implementation uses the raw query getter w/o parameters. + """ + 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.""" + return self._device.device_type is DeviceType.Hub 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 new file mode 100644 index 000000000..7f84de1e5 --- /dev/null +++ b/kasa/smartcam/modules/device.py @@ -0,0 +1,88 @@ +"""Implementation of device module.""" + +from __future__ import annotations + +from ...feature import Feature +from ..smartcammodule import SmartCamModule + + +class DeviceModule(SmartCamModule): + """Implementation of device module.""" + + NAME = "devicemodule" + QUERY_GETTER_NAME = "getDeviceInfo" + 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( + Feature( + self._device, + id="device_id", + name="Device ID", + attribute_getter="device_id", + category=Feature.Category.Debug, + 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. + + Overrides the default behaviour to disable a module if the query returns + an error because this module is critical. + """ + + @property + def device_id(self) -> str: + """Return the device 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 new file mode 100644 index 000000000..5b0912e7e --- /dev/null +++ b/kasa/smartcam/modules/led.py @@ -0,0 +1,30 @@ +"""Module for led controls.""" + +from __future__ import annotations + +from ...interfaces.led import Led as LedInterface +from ...smart.smartmodule import allow_update_after +from ..smartcammodule import SmartCamModule + + +class Led(SmartCamModule, LedInterface): + """Implementation of led controls.""" + + REQUIRED_COMPONENT = "led" + QUERY_GETTER_NAME = "getLedStatus" + QUERY_MODULE_NAME = "led" + QUERY_SECTION_NAMES = "config" + + @property + 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. + + This should probably be a select with always/never/nightmode. + """ + params = {"enabled": "on"} if enable else {"enabled": "off"} + return await self.call("setLedStatus", {"led": {"config": params}}) 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 new file mode 100644 index 000000000..52b2db0d7 --- /dev/null +++ b/kasa/smartcam/modules/pantilt.py @@ -0,0 +1,175 @@ +"""Implementation of pan/tilt module.""" + +from __future__ import annotations + +from ...feature import Feature +from ..smartcammodule import SmartCamModule + +DEFAULT_PAN_STEP = 30 +DEFAULT_TILT_STEP = 10 + + +class PanTilt(SmartCamModule): + """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 + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + + async def set_pan_step(value: int) -> None: + self._pan_step = value + + async def set_tilt_step(value: int) -> None: + self._tilt_step = value + + self._add_feature( + Feature( + self._device, + "pan_right", + "Pan right", + container=self, + attribute_setter=lambda: self.pan(self._pan_step * -1), + type=Feature.Type.Action, + ) + ) + self._add_feature( + Feature( + self._device, + "pan_left", + "Pan left", + container=self, + attribute_setter=lambda: self.pan(self._pan_step), + type=Feature.Type.Action, + ) + ) + self._add_feature( + Feature( + self._device, + "pan_step", + "Pan step", + container=self, + attribute_getter="_pan_step", + attribute_setter=set_pan_step, + type=Feature.Type.Number, + ) + ) + self._add_feature( + Feature( + self._device, + "tilt_up", + "Tilt up", + container=self, + attribute_setter=lambda: self.tilt(self._tilt_step), + type=Feature.Type.Action, + ) + ) + self._add_feature( + Feature( + self._device, + "tilt_down", + "Tilt down", + container=self, + attribute_setter=lambda: self.tilt(self._tilt_step * -1), + type=Feature.Type.Action, + ) + ) + self._add_feature( + Feature( + self._device, + "tilt_step", + "Tilt step", + container=self, + attribute_getter="_tilt_step", + attribute_setter=set_tilt_step, + type=Feature.Type.Number, + ) + ) + + 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) + + async def tilt(self, tilt: int) -> dict: + """Tilt vertically.""" + return await self.move(pan=0, tilt=tilt) + + async def move(self, *, pan: int, tilt: int) -> dict: + """Pan and tilt camera.""" + 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 new file mode 100644 index 000000000..54ee30e53 --- /dev/null +++ b/kasa/smartcam/modules/time.py @@ -0,0 +1,92 @@ +"""Implementation of time module.""" + +from __future__ import annotations + +from datetime import UTC, datetime, tzinfo +from typing import cast +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from ...cachedzoneinfo import CachedZoneInfo +from ...feature import Feature +from ...interfaces import Time as TimeInterface +from ...smart.smartmodule import allow_update_after +from ..smartcammodule import SmartCamModule + + +class Time(SmartCamModule, TimeInterface): + """Implementation of device_local_time.""" + + QUERY_GETTER_NAME = "getTimezone" + QUERY_MODULE_NAME = "system" + QUERY_SECTION_NAMES = "basic" + + _timezone: tzinfo = UTC + _time: datetime + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + device=self._device, + id="device_time", + name="Device time", + attribute_getter="time", + container=self, + category=Feature.Category.Debug, + type=Feature.Type.Sensor, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + q = super().query() + q["getClockStatus"] = {self.QUERY_MODULE_NAME: {"name": "clock_status"}} + + return q + + async def _post_update_hook(self) -> None: + """Perform actions after a device update.""" + time_data = self.data["getClockStatus"]["system"]["clock_status"] + timezone_data = self.data["getTimezone"]["system"]["basic"] + zone_id = timezone_data["zone_id"] + timestamp = time_data["seconds_from_1970"] + try: + # Zoneinfo will return a DST aware object + tz: tzinfo = await CachedZoneInfo.get_cached_zone_info(zone_id) + except ZoneInfoNotFoundError: + # timezone string like: UTC+10:00 + timezone_str = timezone_data["timezone"] + tz = cast(tzinfo, datetime.strptime(timezone_str[-6:], "%z").tzinfo) + + self._timezone = tz + self._time = datetime.fromtimestamp( + cast(float, timestamp), + tz=tz, + ) + + @property + def timezone(self) -> tzinfo: + """Return current timezone.""" + return self._timezone + + @property + 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 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 new file mode 100644 index 000000000..7bc6184f3 --- /dev/null +++ b/kasa/smartcam/smartcamdevice.py @@ -0,0 +1,392 @@ +"""Module for SmartCamDevice.""" + +from __future__ import annotations + +import base64 +import logging +from typing import Any, cast + +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 + +_LOGGER = logging.getLogger(__name__) + + +class SmartCamDevice(SmartDevice): + """Class for smart cameras.""" + + # 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 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: + """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"] + 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", + device_family=basic_info["device_type"], + device_type=device_type, + hardware_version=basic_info["hw_version"], + firmware_version=firmware_version, + firmware_build=firmware_build, + requires_auth=True, + region=basic_info.get("region"), + ) + + def _update_internal_info(self, info_resp: dict) -> None: + """Update the internal device info.""" + info = self._try_get_response(info_resp, "getDeviceInfo") + self._info = self._map_info(info["device_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.get("device_id") + if child_id not in self._children: + # _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_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) + try: + initial_response = await child_protocol.query( + {"get_connect_cloud_state": None} + ) + except Exception as ex: + _LOGGER.exception("Error initialising child %s: %s", child_id, ex) + + return await SmartChildDevice.create( + parent=self, + child_info=info, + 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 = { + "getChildDeviceList": {"childControl": {"start_index": 0}}, + "getChildDeviceComponentList": {"childControl": {"start_index": 0}}, + } + resp = await self.protocol.query(child_info_query) + self.internal_state.update(resp) + + 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) + + return None + + async def _initialize_modules(self) -> None: + """Initialize modules based on component negotiation response.""" + for mod in SmartCamModule.REGISTERED_MODULES.values(): + if ( + mod.REQUIRED_COMPONENT + and mod.REQUIRED_COMPONENT not in self._components + ): + continue + module = mod(self, mod._module_name()) + if await module._check_supported(): + self._modules[module.name] = module + + async def _initialize_features(self) -> None: + """Initialize device features.""" + for module in self.modules.values(): + module._initialize_features() + for feat in module._module_features.values(): + self._add_feature(feat) + + async def _query_setter_helper( + self, method: str, module: str, section: str, params: dict | None = None + ) -> dict: + res = await self.protocol.query({method: {module: {section: params}}}) + + 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. + + We fetch the device info and the available components as early as possible. + If the device reports supporting child devices, they are also initialized. + """ + 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_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"] + 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: + """Return true if the device is on.""" + if (camera := self.modules.get(Module.Camera)) and not camera.disabled: + return camera.is_on + + return True + + async def set_state(self, on: bool) -> dict: + """Set the device state.""" + if (camera := self.modules.get(Module.Camera)) and not camera.disabled: + return await camera.set_state(on) + + return {} + + @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 + + @property + def alias(self) -> str | None: + """Returns the device alias or nickname.""" + if self._info: + return self._info.get("alias") + return None + + async def set_alias(self, alias: str) -> dict: + """Set the device name (alias).""" + return await self.protocol.query( + { + "setDeviceAlias": {"system": {"sys": {"dev_alias": alias}}}, + } + ) + + @property + def hw_info(self) -> dict: + """Return hardware info for the device.""" + return { + "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 new file mode 100644 index 000000000..7402b8cb8 --- /dev/null +++ b/kasa/smartcam/smartcammodule.py @@ -0,0 +1,127 @@ +"""Base implementation for SMART modules.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Final + +from ..exceptions import DeviceError, KasaException, SmartErrorCode +from ..modulemapping import ModuleName +from ..smart.smartmodule import SmartModule + +if TYPE_CHECKING: + from . import modules + from .smartcamdevice import SmartCamDevice + +_LOGGER = logging.getLogger(__name__) + + +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" + ) + + #: Module name to be queried + QUERY_MODULE_NAME: str + #: Section name or names to be queried + QUERY_SECTION_NAMES: str | list[str] | None = None + + REGISTERED_MODULES = {} + + _device: SmartCamDevice + + def query(self) -> dict: + """Query to execute during the update cycle. + + 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 {} + ) + return {self.QUERY_GETTER_NAME: {self.QUERY_MODULE_NAME: section_names}} + + async def call(self, method: str, params: dict | None = None) -> dict: + """Call a method. + + Just a helper method. + """ + return await self._device._query_helper(method, params) + + @property + def data(self) -> dict: + """Return response data for the module.""" + dev = self._device + q = self.query() + + if not q: + return dev.sys_info + + if len(q) == 1: + query_resp = dev._last_update.get(self.QUERY_GETTER_NAME, {}) + if isinstance(query_resp, SmartErrorCode): + raise DeviceError( + f"Error accessing module data in {self._module}", + error_code=query_resp, + ) + + if not query_resp: + raise KasaException( + f"You need to call update() prior accessing module data" + f" for '{self._module}'" + ) + + # 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: + if key not in found: + raise KasaException( + f"{key} not found, you need to call update() prior accessing" + f" module data for '{self._module}'" + ) + if isinstance(found[key], SmartErrorCode): + raise DeviceError( + f"Error accessing module data {key} in {self._module}", + error_code=found[key], + ) + return found diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py deleted file mode 100755 index 19589bbad..000000000 --- a/kasa/smartdevice.py +++ /dev/null @@ -1,745 +0,0 @@ -"""Python library supporting TP-Link Smart Home devices. - -The communication protocol was reverse engineered by Lubomir Stroetmann and -Tobias Esser in 'Reverse Engineering the TP-Link HS110': -https://www.softscheck.com/en/reverse-engineering-tp-link-hs110/ - -This library reuses codes and concepts of the TP-Link WiFi SmartPlug Client -at https://github.com/softScheck/tplink-smartplug, developed by Lubomir -Stroetmann which is licensed under the Apache License, Version 2.0. - -You may obtain a copy of the license at -http://www.apache.org/licenses/LICENSE-2.0 -""" -import functools -import inspect -import logging -from dataclasses import dataclass -from datetime import datetime, timedelta -from enum import Enum -from typing import Any, Dict, List, Optional - -from .exceptions import SmartDeviceException -from .protocol import TPLinkSmartHomeProtocol - -_LOGGER = logging.getLogger(__name__) - - -class DeviceType(Enum): - """Device type enum.""" - - Plug = 1 - Bulb = 2 - Strip = 3 - Dimmer = 4 - LightStrip = 5 - Unknown = -1 - - -@dataclass -class WifiNetwork: - """Wifi network container.""" - - ssid: str - key_type: int - # These are available only on softaponboarding - cipher_type: Optional[int] = None - bssid: Optional[str] = None - channel: Optional[int] = None - rssi: Optional[int] = None - - -class EmeterStatus(dict): - """Container for converting different representations of emeter data. - - Newer FW/HW versions postfix the variable names with the used units, - where-as the olders do not have this feature. - - This class automatically converts between these two to allow - backwards and forwards compatibility. - """ - - def __getitem__(self, item): - valid_keys = [ - "voltage_mv", - "power_mw", - "current_ma", - "energy_wh", - "total_wh", - "voltage", - "power", - "current", - "total", - "energy", - ] - - # 1. if requested data is available, return it - if item in super().keys(): - return super().__getitem__(item) - # otherwise decide how to convert it - else: - if item not in valid_keys: - raise KeyError(item) - if "_" in item: # upscale - return super().__getitem__(item[: item.find("_")]) * 1000 - else: # downscale - for i in super().keys(): - if i.startswith(item): - return self.__getitem__(i) / 1000 - - raise SmartDeviceException("Unable to find a value for '%s'" % item) - - -def requires_update(f): - """Indicate that `update` should be called before accessing this method.""" # noqa: D202 - if inspect.iscoroutinefunction(f): - - @functools.wraps(f) - async def wrapped(*args, **kwargs): - self = args[0] - if self._last_update is None: - raise SmartDeviceException( - "You need to await update() to access the data" - ) - return await f(*args, **kwargs) - - else: - - @functools.wraps(f) - def wrapped(*args, **kwargs): - self = args[0] - if self._last_update is None: - raise SmartDeviceException( - "You need to await update() to access the data" - ) - return f(*args, **kwargs) - - f.requires_update = True - return wrapped - - -class SmartDevice: - """Base class for all supported device types. - - You don't usually want to construct this class which implements the shared common interfaces. - The recommended way is to either use the discovery functionality, or construct one of the subclasses: - - * :class:`SmartPlug` - * :class:`SmartBulb` - * :class:`SmartStrip` - * :class:`SmartDimmer` - * :class:`SmartLightStrip` - - To initialize, you have to await :func:`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 update() separately. - - Errors reported by the device are raised as SmartDeviceExceptions, - and should be handled by the user of the library. - - Examples: - >>> import asyncio - >>> dev = SmartDevice("127.0.0.1") - >>> asyncio.run(dev.update()) - - All devices provide several informational properties: - - >>> dev.alias - Kitchen - >>> dev.model - HS110(EU) - >>> dev.rssi - -71 - >>> dev.mac - 50:C7:BF:01:F8:CD - - Some information can also be changed programatically: - - >>> asyncio.run(dev.set_alias("new alias")) - >>> asyncio.run(dev.set_mac("01:23:45:67:89:ab")) - >>> asyncio.run(dev.update()) - >>> dev.alias - new alias - >>> dev.mac - 01:23:45:67:89:ab - - When initialized using discovery or using a subclass, you can check the type of the device: - - >>> dev.is_bulb - False - >>> dev.is_strip - False - >>> dev.is_plug - True - - You can also get the hardware and software as a dict, or access the full device response: - - >>> dev.hw_info - {'sw_ver': '1.2.5 Build 171213 Rel.101523', - 'hw_ver': '1.0', - 'mac': '01:23:45:67:89:ab', - 'type': 'IOT.SMARTPLUGSWITCH', - 'hwId': '45E29DA8382494D2E82688B52A0B2EB5', - 'fwId': '00000000000000000000000000000000', - 'oemId': '3D341ECE302C0642C99E31CE2430544B', - 'dev_name': 'Wi-Fi Smart Plug With Energy Monitoring'} - >>> dev.sys_info - - All devices can be turned on and off: - - >>> asyncio.run(dev.turn_off()) - >>> asyncio.run(dev.turn_on()) - >>> asyncio.run(dev.update()) - >>> dev.is_on - True - - Some devices provide energy consumption meter, and regular update will already fetch some information: - - >>> dev.has_emeter - True - >>> dev.emeter_realtime - {'current': 0.015342, 'err_code': 0, 'power': 0.983971, 'total': 32.448, 'voltage': 235.595234} - >>> dev.emeter_today - >>> dev.emeter_this_month - - You can also query the historical data (note that these needs to be awaited), keyed with month/day: - - >>> asyncio.run(dev.get_emeter_monthly(year=2016)) - {11: 1.089, 12: 1.582} - >>> asyncio.run(dev.get_emeter_daily(year=2016, month=11)) - {24: 0.026, 25: 0.109} - - """ - - def __init__(self, host: str) -> None: - """Create a new SmartDevice instance. - - :param str host: host name or ip address on which the device listens - """ - self.host = host - - self.protocol = TPLinkSmartHomeProtocol() - self.emeter_type = "emeter" - _LOGGER.debug("Initializing %s of type %s", self.host, type(self)) - self._device_type = DeviceType.Unknown - # TODO: typing Any is just as using Optional[Dict] would require separate checks in - # accessors. the @updated_required decorator does not ensure mypy that these - # are not accessed incorrectly. - self._last_update: Any = None - self._sys_info: Any = None # TODO: this is here to avoid changing tests - - self.children: List["SmartDevice"] = [] - - def _create_request( - self, target: str, cmd: str, arg: Optional[Dict] = None, child_ids=None - ): - request: Dict[str, Any] = {target: {cmd: arg}} - if child_ids is not None: - request = {"context": {"child_ids": child_ids}, target: {cmd: arg}} - - return request - - async def _query_helper( - self, target: str, cmd: str, arg: Optional[Dict] = None, child_ids=None - ) -> Any: - """Query device, return results or raise an exception. - - :param target: Target system {system, time, emeter, ..} - :param cmd: Command to execute - :param arg: payload dict to be send to the device - :param child_ids: ids of child devices - :return: Unwrapped result for the call. - """ - request = self._create_request(target, cmd, arg, child_ids) - - try: - response = await self.protocol.query(host=self.host, request=request) - except Exception as ex: - raise SmartDeviceException(f"Communication error on {target}:{cmd}") from ex - - if target not in response: - raise SmartDeviceException(f"No required {target} in response: {response}") - - result = response[target] - if "err_code" in result and result["err_code"] != 0: - raise SmartDeviceException(f"Error on {target}.{cmd}: {result}") - - if cmd not in result: - raise SmartDeviceException(f"No command in response: {response}") - result = result[cmd] - if "err_code" in result and result["err_code"] != 0: - raise SmartDeviceException(f"Error on {target} {cmd}: {result}") - - if "err_code" in result: - del result["err_code"] - - return result - - @property # type: ignore - @requires_update - def has_emeter(self) -> bool: - """Return True if device has an energy meter.""" - sys_info = self.sys_info - features = sys_info["feature"].split(":") - return "ENE" in features - - async def get_sys_info(self) -> Dict[str, Any]: - """Retrieve system information.""" - return await self._query_helper("system", "get_sysinfo") - - async def update(self): - """Update some of the attributes. - - Needed for methods that are decorated with `requires_update`. - """ - req = {} - req.update(self._create_request("system", "get_sysinfo")) - - # Check for emeter if we were never updated, or if the device has emeter - if self._last_update is None or self.has_emeter: - req.update(self._create_emeter_request()) - self._last_update = await self.protocol.query(self.host, req) - # TODO: keep accessible for tests - self._sys_info = self._last_update["system"]["get_sysinfo"] - - @property # type: ignore - @requires_update - def sys_info(self) -> Dict[str, Any]: - """Return system information.""" - 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 # type: ignore - @requires_update - def alias(self) -> str: - """Return device name (alias).""" - sys_info = self.sys_info - return str(sys_info["alias"]) - - async def set_alias(self, alias: str) -> None: - """Set the device name (alias).""" - return await self._query_helper("system", "set_dev_alias", {"alias": alias}) - - async def get_time(self) -> Optional[datetime]: - """Return current time from the device, if available.""" - try: - res = await self._query_helper("time", "get_time") - return datetime( - res["year"], - res["month"], - res["mday"], - res["hour"], - res["min"], - res["sec"], - ) - except SmartDeviceException: - return None - - async def get_timezone(self) -> Dict: - """Return timezone information.""" - return await self._query_helper("time", "get_timezone") - - @property # type: ignore - @requires_update - def hw_info(self) -> Dict: - """Return hardware information. - - This returns just a selection of sysinfo keys that are related to hardware. - """ - keys = [ - "sw_ver", - "hw_ver", - "mac", - "mic_mac", - "type", - "mic_type", - "hwId", - "fwId", - "oemId", - "dev_name", - ] - sys_info = self.sys_info - return {key: sys_info[key] for key in keys if key in sys_info} - - @property # type: ignore - @requires_update - def location(self) -> Dict: - """Return geographical location.""" - sys_info = self.sys_info - loc = {"latitude": None, "longitude": None} - - if "latitude" in sys_info and "longitude" in sys_info: - loc["latitude"] = sys_info["latitude"] - loc["longitude"] = sys_info["longitude"] - elif "latitude_i" in sys_info and "longitude_i" in sys_info: - loc["latitude"] = sys_info["latitude_i"] - loc["longitude"] = sys_info["longitude_i"] - else: - _LOGGER.warning("Unsupported device location.") - - return loc - - @property # type: ignore - @requires_update - def rssi(self) -> Optional[int]: - """Return WiFi signal strenth (rssi).""" - sys_info = self.sys_info - if "rssi" in sys_info: - return int(sys_info["rssi"]) - return None - - @property # type: ignore - @requires_update - def mac(self) -> str: - """Return mac address. - - :return: mac address in hexadecimal with colons, e.g. 01:23:45:67:89:ab - """ - sys_info = self.sys_info - - if "mac" in sys_info: - return str(sys_info["mac"]) - elif "mic_mac" in sys_info: - return ":".join( - format(s, "02x") for s in bytes.fromhex(sys_info["mic_mac"]) - ) - - raise SmartDeviceException( - "Unknown mac, please submit a bug report with sys_info output." - ) - - async def set_mac(self, mac): - """Set the mac address. - - :param str mac: mac in hexadecimal with colons, e.g. 01:23:45:67:89:ab - """ - return await self._query_helper("system", "set_mac_addr", {"mac": mac}) - - @property # type: ignore - @requires_update - def emeter_realtime(self) -> EmeterStatus: - """Return current energy readings.""" - if not self.has_emeter: - raise SmartDeviceException("Device has no emeter") - - return EmeterStatus(self._last_update[self.emeter_type]["get_realtime"]) - - async def get_emeter_realtime(self) -> EmeterStatus: - """Retrieve current energy readings.""" - if not self.has_emeter: - raise SmartDeviceException("Device has no emeter") - - return EmeterStatus(await self._query_helper(self.emeter_type, "get_realtime")) - - def _create_emeter_request(self, year: int = None, month: int = None): - """Create a Internal method for building a request for all emeter statistics at once.""" - if year is None: - year = datetime.now().year - if month is None: - month = datetime.now().month - - import collections.abc - - def update(d, u): - """Update dict recursively.""" - for k, v in u.items(): - if isinstance(v, collections.abc.Mapping): - d[k] = update(d.get(k, {}), v) - else: - d[k] = v - return d - - req: Dict[str, Any] = {} - update(req, self._create_request(self.emeter_type, "get_realtime")) - update( - req, self._create_request(self.emeter_type, "get_monthstat", {"year": year}) - ) - update( - req, - self._create_request( - self.emeter_type, "get_daystat", {"month": month, "year": year} - ), - ) - - return req - - @property # type: ignore - @requires_update - def emeter_today(self) -> Optional[float]: - """Return today's energy consumption in kWh.""" - if not self.has_emeter: - raise SmartDeviceException("Device has no emeter") - - raw_data = self._last_update[self.emeter_type]["get_daystat"]["day_list"] - data = self._emeter_convert_emeter_data(raw_data) - today = datetime.now().day - - if today in data: - return data[today] - - return None - - @property # type: ignore - @requires_update - def emeter_this_month(self) -> Optional[float]: - """Return this month's energy consumption in kWh.""" - if not self.has_emeter: - raise SmartDeviceException("Device has no emeter") - - raw_data = self._last_update[self.emeter_type]["get_monthstat"]["month_list"] - data = self._emeter_convert_emeter_data(raw_data) - current_month = datetime.now().month - - if current_month in data: - return data[current_month] - - return None - - def _emeter_convert_emeter_data(self, data, kwh=True) -> Dict: - """Return emeter information keyed with the day/month..""" - response = [EmeterStatus(**x) for x in data] - - if not response: - return {} - - energy_key = "energy_wh" - if kwh: - energy_key = "energy" - - entry_key = "month" - if "day" in response[0]: - entry_key = "day" - - data = {entry[entry_key]: entry[energy_key] for entry in response} - - return data - - async def get_emeter_daily( - self, year: int = None, month: int = None, kwh: bool = True - ) -> Dict: - """Retrieve daily statistics for a given month. - - :param year: year for which to retrieve statistics (default: this year) - :param month: month for which to retrieve statistics (default: this - month) - :param kwh: return usage in kWh (default: True) - :return: mapping of day of month to value - """ - if not self.has_emeter: - raise SmartDeviceException("Device has no emeter") - - if year is None: - year = datetime.now().year - if month is None: - month = datetime.now().month - - response = await self._query_helper( - self.emeter_type, "get_daystat", {"month": month, "year": year} - ) - - return self._emeter_convert_emeter_data(response["day_list"], kwh) - - @requires_update - async def get_emeter_monthly(self, year: int = None, kwh: bool = True) -> Dict: - """Retrieve monthly statistics for a given year. - - :param year: year for which to retrieve statistics (default: this year) - :param kwh: return usage in kWh (default: True) - :return: dict: mapping of month to value - """ - if not self.has_emeter: - raise SmartDeviceException("Device has no emeter") - - if year is None: - year = datetime.now().year - - response = await self._query_helper( - self.emeter_type, "get_monthstat", {"year": year} - ) - - return self._emeter_convert_emeter_data(response["month_list"], kwh) - - @requires_update - async def erase_emeter_stats(self) -> Dict: - """Erase energy meter statistics.""" - if not self.has_emeter: - raise SmartDeviceException("Device has no emeter") - - return await self._query_helper(self.emeter_type, "erase_emeter_stat", None) - - @requires_update - async def current_consumption(self) -> float: - """Get the current power consumption in Watt.""" - if not self.has_emeter: - raise SmartDeviceException("Device has no emeter") - - response = EmeterStatus(await self.get_emeter_realtime()) - return response["power"] - - async def reboot(self, delay: int = 1) -> None: - """Reboot the device. - - Note that giving a delay of zero causes this to block, - as the device reboots immediately without responding to the call. - """ - await self._query_helper("system", "reboot", {"delay": delay}) - - async def turn_off(self, **kwargs) -> Dict: - """Turn off the device.""" - raise NotImplementedError("Device subclass needs to implement this.") - - @property # type: ignore - @requires_update - def is_off(self) -> bool: - """Return True if device is off.""" - return not self.is_on - - async def turn_on(self, **kwargs) -> Dict: - """Turn device on.""" - raise NotImplementedError("Device subclass needs to implement this.") - - @property # type: ignore - @requires_update - def is_on(self) -> bool: - """Return True if the device is on.""" - raise NotImplementedError("Device subclass needs to implement this.") - - @property # type: ignore - @requires_update - def on_since(self) -> Optional[datetime]: - """Return pretty-printed on-time, or None if not available.""" - if "on_time" not in self.sys_info: - return None - - if self.is_off: - return None - - on_time = self.sys_info["on_time"] - - return datetime.now() - timedelta(seconds=on_time) - - @property # type: ignore - @requires_update - def state_information(self) -> Dict[str, Any]: - """Return device-type specific, end-user friendly state information.""" - raise NotImplementedError("Device subclass needs to implement this.") - - @property # type: ignore - @requires_update - def device_id(self) -> str: - """Return unique ID for the device. - - This is the MAC address of the device. - """ - return self.mac - - async def wifi_scan(self) -> List[WifiNetwork]: # noqa: D202 - """Scan for available wifi networks.""" - - async def _scan(target): - return await self._query_helper(target, "get_scaninfo", {"refresh": 1}) - - try: - info = await _scan("netif") - except SmartDeviceException as ex: - _LOGGER.debug( - "Unable to scan using 'netif', retrying with 'softaponboarding': %s", ex - ) - info = await _scan("smartlife.iot.common.softaponboarding") - - if "ap_list" not in info: - raise SmartDeviceException("Invalid response for wifi scan: %s" % info) - - return [WifiNetwork(**x) for x in info["ap_list"]] - - async def wifi_join(self, ssid, password, keytype=3): # noqa: D202 - """Join the given wifi network. - - If joining the network fails, the device will return to AP mode after a while. - """ - - async def _join(target, payload): - return await self._query_helper(target, "set_stainfo", payload) - - payload = {"ssid": ssid, "password": password, "key_type": keytype} - try: - return await _join("netif", payload) - except SmartDeviceException as ex: - _LOGGER.debug( - "Unable to join using 'netif', retrying with 'softaponboarding': %s", ex - ) - return await _join("smartlife.iot.common.softaponboarding", payload) - - def get_plug_by_name(self, name: str) -> "SmartDevice": - """Return child device for the given name.""" - for p in self.children: - if p.alias == name: - return p - - raise SmartDeviceException(f"Device has no child with {name}") - - def get_plug_by_index(self, index: int) -> "SmartDevice": - """Return child device for the given index.""" - if index + 1 > len(self.children) or index < 0: - raise SmartDeviceException( - f"Invalid index {index}, device has {len(self.children)} plugs" - ) - return self.children[index] - - @property - def device_type(self) -> DeviceType: - """Return the device type.""" - return self._device_type - - @property - def is_bulb(self) -> bool: - """Return True if the device is a bulb.""" - return self._device_type == DeviceType.Bulb - - @property - def is_light_strip(self) -> bool: - """Return True if the device is a led strip.""" - return self._device_type == DeviceType.LightStrip - - @property - def is_plug(self) -> bool: - """Return True if the device is a plug.""" - return self._device_type == DeviceType.Plug - - @property - def is_strip(self) -> bool: - """Return True if the device is a strip.""" - return self._device_type == DeviceType.Strip - - @property - def is_dimmer(self) -> bool: - """Return True if the device is a dimmer.""" - return self._device_type == DeviceType.Dimmer - - @property - def is_dimmable(self) -> bool: - """Return True if the device is dimmable.""" - return False - - @property - def is_variable_color_temp(self) -> bool: - """Return True if the device supports color temperature.""" - return False - - @property - def is_color(self) -> bool: - """Return True if the device supports color changes.""" - return False - - def __repr__(self): - if self._last_update is None: - return f"<{self._device_type} at {self.host} - update() needed>" - return f"<{self._device_type} model {self.model} at {self.host} ({self.alias}), is_on: {self.is_on} - dev specific: {self.state_information}>" diff --git a/kasa/smartdimmer.py b/kasa/smartdimmer.py deleted file mode 100644 index 8e5cb1527..000000000 --- a/kasa/smartdimmer.py +++ /dev/null @@ -1,150 +0,0 @@ -"""Module for dimmers (currently only HS220).""" -from typing import Any, Dict - -from kasa.smartdevice import DeviceType, SmartDeviceException, requires_update -from kasa.smartplug import SmartPlug - - -class SmartDimmer(SmartPlug): - """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. - - To initialize, you have to await :func:`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. - - Errors reported by the device are raised as :class:`SmartDeviceException`s, - and should be handled by the user of the library. - - Examples: - >>> import asyncio - >>> dimmer = SmartDimmer("192.168.1.105") - >>> asyncio.run(dimmer.turn_on()) - >>> dimmer.brightness - 25 - - >>> asyncio.run(dimmer.set_brightness(50)) - >>> asyncio.run(dimmer.update()) - >>> dimmer.brightness - 50 - - Refer to :class:`SmartPlug` for the full API. - """ - - DIMMER_SERVICE = "smartlife.iot.dimmer" - - def __init__(self, host: str) -> None: - super().__init__(host) - self._device_type = DeviceType.Dimmer - - @property # type: ignore - @requires_update - def brightness(self) -> int: - """Return current brightness on dimmers. - - Will return a range between 0 - 100. - """ - if not self.is_dimmable: - raise SmartDeviceException("Device is not dimmable.") - - sys_info = self.sys_info - return int(sys_info["brightness"]) - - @requires_update - async def set_brightness(self, brightness: int, *, transition: int = None): - """Set the new dimmer brightness level in percentage. - - :param int transition: transition duration in milliseconds. - Using a transition will cause the dimmer to turn on. - """ - if not self.is_dimmable: - raise SmartDeviceException("Device is not dimmable.") - - if not isinstance(brightness, int): - raise ValueError( - "Brightness must be integer, " "not of %s.", type(brightness) - ) - - if not 0 <= brightness <= 100: - raise ValueError("Brightness value %s is not valid." % brightness) - - # Dimmers do not support a brightness of 0, but bulbs do. - # Coerce 0 to 1 to maintain the same interface between dimmers and bulbs. - if brightness == 0: - brightness = 1 - - if transition is not None: - return await self.set_dimmer_transition(brightness, transition) - - return await self._query_helper( - self.DIMMER_SERVICE, "set_brightness", {"brightness": brightness} - ) - - async def turn_off(self, *, transition: int = None, **kwargs): - """Turn the bulb off. - - :param int transition: transition duration in milliseconds. - """ - if transition is not None: - return await self.set_dimmer_transition(brightness=0, transition=transition) - - return await super().turn_off() - - @requires_update - async def turn_on(self, *, transition: int = None, **kwargs): - """Turn the bulb on. - - :param int transition: transition duration in milliseconds. - """ - if transition is not None: - return await self.set_dimmer_transition( - brightness=self.brightness, transition=transition - ) - - return await super().turn_on() - - async def set_dimmer_transition(self, brightness: int, transition: int): - """Turn the bulb on to brightness percentage over transition milliseconds. - - A brightness value of 0 will turn off the dimmer. - """ - if not isinstance(brightness, int): - raise ValueError( - "Brightness must be integer, " "not of %s.", type(brightness) - ) - - if not 0 <= brightness <= 100: - raise ValueError("Brightness value %s is not valid." % brightness) - - if not isinstance(transition, int): - raise ValueError( - "Transition must be integer, " "not of %s.", type(transition) - ) - if transition <= 0: - raise ValueError("Transition value %s is not valid." % transition) - - return await self._query_helper( - self.DIMMER_SERVICE, - "set_dimmer_transition", - {"brightness": brightness, "duration": transition}, - ) - - @property # type: ignore - @requires_update - def is_dimmable(self) -> bool: - """Whether the switch supports brightness changes.""" - sys_info = self.sys_info - return "brightness" in sys_info - - @property # type: ignore - @requires_update - def state_information(self) -> Dict[str, Any]: - """Return switch-specific state information.""" - info = super().state_information - info["Brightness"] = self.brightness - - return info diff --git a/kasa/smartlightstrip.py b/kasa/smartlightstrip.py deleted file mode 100644 index c579fec20..000000000 --- a/kasa/smartlightstrip.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Module for light strips (KL430).""" -from typing import Any, Dict - -from .smartbulb import SmartBulb -from .smartdevice import DeviceType, requires_update - - -class SmartLightStrip(SmartBulb): - """Representation of a TP-Link Smart light strip. - - 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. - - Examples: - >>> import asyncio - >>> strip = SmartLightStrip("127.0.0.1") - >>> asyncio.run(strip.update()) - >>> print(strip.alias) - KL430 pantry lightstrip - - Getting the length of the strip: - - >>> strip.length - 16 - - Currently active effect: - - >>> strip.effect - {'brightness': 50, 'custom': 0, 'enable': 0, 'id': '', 'name': ''} - - .. note:: - The device supports some features that are not currently implemented, - feel free to find out how to control them and create a PR! - - - See :class:`SmartBulb` for more examples. - """ - - LIGHT_SERVICE = "smartlife.iot.lightStrip" - SET_LIGHT_METHOD = "set_light_state" - - def __init__(self, host: str) -> None: - super().__init__(host) - self._device_type = DeviceType.LightStrip - - @property # type: ignore - @requires_update - def length(self) -> int: - """Return length of the strip.""" - return self.sys_info["length"] - - @property # type: ignore - @requires_update - def effect(self) -> Dict: - """Return effect state. - - Example: - {'brightness': 50, - 'custom': 0, - 'enable': 0, - 'id': '', - 'name': ''} - """ - return self.sys_info["lighting_effect_state"] - - @property # type: ignore - @requires_update - def state_information(self) -> Dict[str, Any]: - """Return strip specific state information.""" - info = super().state_information - - info["Length"] = self.length - - return info diff --git a/kasa/smartplug.py b/kasa/smartplug.py deleted file mode 100644 index d23bc9396..000000000 --- a/kasa/smartplug.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Module for smart plugs (HS100, HS110, ..).""" -import logging -from typing import Any, Dict - -from kasa.smartdevice import DeviceType, SmartDevice, requires_update - -_LOGGER = logging.getLogger(__name__) - - -class SmartPlug(SmartDevice): - """Representation of a TP-Link Smart Switch. - - To initialize, you have to await :func:`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. - - Errors reported by the device are raised as :class:`SmartDeviceException`s, - and should be handled by the user of the library. - - Examples: - >>> import asyncio - >>> plug = SmartPlug("127.0.0.1") - >>> asyncio.run(plug.update()) - >>> plug.alias - Kitchen - - Setting the LED state: - - >>> asyncio.run(plug.set_led(True)) - >>> asyncio.run(plug.update()) - >>> plug.led - True - - For more examples, see the :class:`SmartDevice` class. - """ - - def __init__(self, host: str) -> None: - super().__init__(host) - self.emeter_type = "emeter" - self._device_type = DeviceType.Plug - - @property # type: ignore - @requires_update - def is_on(self) -> bool: - """Return whether device is on.""" - sys_info = self.sys_info - return bool(sys_info["relay_state"]) - - async def turn_on(self, **kwargs): - """Turn the switch on.""" - return await self._query_helper("system", "set_relay_state", {"state": 1}) - - async def turn_off(self, **kwargs): - """Turn the switch off.""" - return await self._query_helper("system", "set_relay_state", {"state": 0}) - - @property # type: ignore - @requires_update - def led(self) -> bool: - """Return the state of the led.""" - sys_info = self.sys_info - return bool(1 - sys_info["led_off"]) - - async def set_led(self, state: bool): - """Set the state of the led (night mode).""" - return await self._query_helper( - "system", "set_led_off", {"off": int(not state)} - ) - - @property # type: ignore - @requires_update - def state_information(self) -> Dict[str, Any]: - """Return switch-specific state information.""" - info = {"LED state": self.led, "On since": self.on_since} - return info diff --git a/kasa/smartstrip.py b/kasa/smartstrip.py deleted file mode 100755 index 222c73e45..000000000 --- a/kasa/smartstrip.py +++ /dev/null @@ -1,304 +0,0 @@ -"""Module for multi-socket devices (HS300, HS107, KP303, ..).""" -import logging -from collections import defaultdict -from datetime import datetime, timedelta -from typing import Any, DefaultDict, Dict, Optional - -from kasa.smartdevice import ( - DeviceType, - SmartDevice, - SmartDeviceException, - requires_update, -) -from kasa.smartplug import SmartPlug - -_LOGGER = logging.getLogger(__name__) - - -class SmartStrip(SmartDevice): - """Representation of a TP-Link Smart Power Strip. - - 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. - - To initialize, you have to await :func:`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. - - Errors reported by the device are raised as :class:`SmartDeviceException`s, - and should be handled by the user of the library. - - Examples: - >>> import asyncio - >>> strip = SmartStrip("127.0.0.1") - >>> asyncio.run(strip.update()) - >>> strip.alias - TP-LINK_Power Strip_CF69 - - All methods act on the whole strip: - - >>> for plug in strip.children: - >>> print(f"{plug.alias}: {plug.is_on}") - Plug 1: True - Plug 2: False - Plug 3: False - >>> strip.is_on - True - >>> asyncio.run(strip.turn_off()) - - Accessing individual plugs can be done using the `children` property: - - >>> len(strip.children) - 3 - >>> for plug in strip.children: - >>> print(f"{plug.alias}: {plug.is_on}") - Plug 1: False - Plug 2: False - Plug 3: False - >>> asyncio.run(strip.children[1].turn_on()) - >>> asyncio.run(strip.update()) - >>> strip.is_on - True - - For more examples, see the :class:`SmartDevice` class. - """ - - def __init__(self, host: str) -> None: - super().__init__(host=host) - self.emeter_type = "emeter" - self._device_type = DeviceType.Strip - - @property # type: ignore - @requires_update - def is_on(self) -> bool: - """Return if any of the outlets are on.""" - for plug in self.children: - is_on = plug.is_on - if is_on: - return True - return False - - async def update(self): - """Update some of the attributes. - - Needed for methods that are decorated with `requires_update`. - """ - await super().update() - - # Initialize the child devices during the first update. - if not self.children: - children = self.sys_info["children"] - _LOGGER.debug("Initializing %s child sockets", len(children)) - for child in children: - self.children.append( - SmartStripPlug(self.host, parent=self, child_id=child["id"]) - ) - - async def turn_on(self, **kwargs): - """Turn the strip on.""" - await self._query_helper("system", "set_relay_state", {"state": 1}) - await self.update() - - async def turn_off(self, **kwargs): - """Turn the strip off.""" - await self._query_helper("system", "set_relay_state", {"state": 0}) - await self.update() - - @property # type: ignore - @requires_update - def on_since(self) -> Optional[datetime]: - """Return the maximum on-time of all outlets.""" - if self.is_off: - return None - - return max(plug.on_since for plug in self.children if plug.on_since is not None) - - @property # type: ignore - @requires_update - def led(self) -> bool: - """Return the state of the led.""" - sys_info = self.sys_info - return bool(1 - sys_info["led_off"]) - - async def set_led(self, state: bool): - """Set the state of the led (night mode).""" - await self._query_helper("system", "set_led_off", {"off": int(not state)}) - await self.update() - - @property # type: ignore - @requires_update - def state_information(self) -> Dict[str, Any]: - """Return strip-specific state information. - - :return: Strip information dict, keys in user-presentable form. - """ - return { - "LED state": self.led, - "Childs count": len(self.children), - "On since": self.on_since, - } - - async def current_consumption(self) -> float: - """Get the current power consumption in watts.""" - consumption = sum([await plug.current_consumption() for plug in self.children]) - - return consumption - - async def set_alias(self, alias: str) -> None: - """Set the alias for the strip. - - :param alias: new alias - """ - return await super().set_alias(alias) - - @requires_update - async def get_emeter_daily( - self, year: int = None, month: int = None, kwh: bool = True - ) -> Dict: - """Retrieve daily statistics for a given month. - - :param year: year for which to retrieve statistics (default: this year) - :param month: month for which to retrieve statistics (default: this - month) - :param kwh: return usage in kWh (default: True) - :return: mapping of day of month to value - """ - emeter_daily: DefaultDict[int, float] = defaultdict(lambda: 0.0) - for plug in self.children: - plug_emeter_daily = await plug.get_emeter_daily( - year=year, month=month, kwh=kwh - ) - for day, value in plug_emeter_daily.items(): - emeter_daily[day] += value - return emeter_daily - - @requires_update - async def get_emeter_monthly(self, year: int = None, kwh: bool = True) -> Dict: - """Retrieve monthly statistics for a given year. - - :param year: year for which to retrieve statistics (default: this year) - :param kwh: return usage in kWh (default: True) - """ - emeter_monthly: DefaultDict[int, float] = defaultdict(lambda: 0.0) - for plug in self.children: - plug_emeter_monthly = await plug.get_emeter_monthly(year=year, kwh=kwh) - for month, value in plug_emeter_monthly: - emeter_monthly[month] += value - - return emeter_monthly - - @requires_update - async def erase_emeter_stats(self): - """Erase energy meter statistics for all plugs.""" - for plug in self.children: - await plug.erase_emeter_stats() - - -class SmartStripPlug(SmartPlug): - """Representation of a single socket in a power strip. - - This allows you to use the sockets as they were SmartPlug objects. - Instead of calling an update on any of these, you should call an update - on the parent device before accessing the properties. - - The plug inherits (most of) the system information from the parent. - """ - - def __init__(self, host: str, parent: "SmartStrip", child_id: str) -> None: - super().__init__(host) - - self.parent = parent - self.child_id = child_id - self._last_update = parent._last_update - self._sys_info = parent._sys_info - - async def update(self): - """Override the update to no-op and inform the user.""" - _LOGGER.warning( - "You called update() on a child device, which has no effect." - "Call update() on the parent device instead." - ) - return - - async def _query_helper( - self, target: str, cmd: str, arg: Optional[Dict] = None, child_ids=None - ) -> Any: - """Override query helper to include the child_ids.""" - return await self.parent._query_helper( - target, cmd, arg, child_ids=[self.child_id] - ) - - @property # type: ignore - @requires_update - def is_on(self) -> bool: - """Return whether device is on.""" - info = self._get_child_info() - return bool(info["state"]) - - @property # type: ignore - @requires_update - def led(self) -> bool: - """Return the state of the led. - - This is always false for subdevices. - """ - return False - - @property # type: ignore - @requires_update - def has_emeter(self) -> bool: - """Children have no emeter to my knowledge.""" - return False - - @property # type: ignore - @requires_update - def device_id(self) -> str: - """Return unique ID for the socket. - - This is a combination of MAC and child's ID. - """ - return f"{self.mac}_{self.child_id}" - - @property # type: ignore - @requires_update - def alias(self) -> str: - """Return device name (alias).""" - info = self._get_child_info() - return info["alias"] - - @property # type: ignore - @requires_update - def next_action(self) -> Dict: - """Return next scheduled(?) action.""" - info = self._get_child_info() - return info["next_action"] - - @property # type: ignore - @requires_update - def on_since(self) -> Optional[datetime]: - """Return on-time, if available.""" - if self.is_off: - return None - - info = self._get_child_info() - on_time = info["on_time"] - - return datetime.now() - timedelta(seconds=on_time) - - @property # type: ignore - @requires_update - def model(self) -> str: - """Return device model for a child socket.""" - sys_info = self.parent.sys_info - return f"Socket for {sys_info['model']}" - - def _get_child_info(self) -> Dict: - """Return the subdevice information for this device.""" - for plug in self.parent.sys_info["children"]: - if plug["id"] == self.child_id: - return plug - - raise SmartDeviceException(f"Unable to find children {self.child_id}") diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py deleted file mode 100644 index 69f1f3b72..000000000 --- a/kasa/tests/conftest.py +++ /dev/null @@ -1,196 +0,0 @@ -import asyncio -import glob -import json -import os -from os.path import basename -from pathlib import Path, PurePath -from unittest.mock import MagicMock - -import pytest # type: ignore # see https://github.com/pytest-dev/pytest/issues/3342 - -from kasa import ( - Discover, - SmartBulb, - SmartDimmer, - SmartLightStrip, - SmartPlug, - SmartStrip, -) - -from .newfakes import FakeTransportProtocol - -SUPPORTED_DEVICES = glob.glob( - os.path.dirname(os.path.abspath(__file__)) + "/fixtures/*.json" -) - - -LIGHT_STRIPS = {"KL430"} -BULBS = {"KL60", "LB100", "LB120", "LB130", "KL120", "KL130", *LIGHT_STRIPS} -VARIABLE_TEMP = {"LB120", "LB130", "KL120", "KL130", "KL430", *LIGHT_STRIPS} -COLOR_BULBS = {"LB130", "KL130", *LIGHT_STRIPS} - - -PLUGS = {"HS100", "HS103", "HS105", "HS110", "HS200", "HS210"} -STRIPS = {"HS107", "HS300", "KP303", "KP400"} -DIMMERS = {"HS220"} - -DIMMABLE = {*BULBS, *DIMMERS} -WITH_EMETER = {"HS110", "HS300", *BULBS, *STRIPS} - -ALL_DEVICES = BULBS.union(PLUGS).union(STRIPS).union(DIMMERS) - - -def filter_model(desc, filter): - filtered = list() - for dev in SUPPORTED_DEVICES: - for filt in filter: - if filt in basename(dev): - filtered.append(dev) - - filtered_basenames = [basename(f) for f in filtered] - print(f"{desc}: {filtered_basenames}") - return filtered - - -def parametrize(desc, devices, ids=None): - # if ids is None: - # ids = ["on", "off"] - return pytest.mark.parametrize( - "dev", filter_model(desc, devices), indirect=True, ids=ids - ) - - -has_emeter = parametrize("has emeter", WITH_EMETER) -no_emeter = parametrize("no emeter", ALL_DEVICES - WITH_EMETER) - - -def name_for_filename(x): - from os.path import basename - - return basename(x) - - -bulb = parametrize("bulbs", BULBS, ids=name_for_filename) -plug = parametrize("plugs", PLUGS, ids=name_for_filename) -strip = parametrize("strips", STRIPS, ids=name_for_filename) -dimmer = parametrize("dimmers", DIMMERS, ids=name_for_filename) -lightstrip = parametrize("lightstrips", LIGHT_STRIPS, ids=name_for_filename) - -# This ensures that every single file inside fixtures/ is being placed in some category -categorized_fixtures = set( - dimmer.args[1] + strip.args[1] + plug.args[1] + bulb.args[1] + lightstrip.args[1] -) -diff = set(SUPPORTED_DEVICES) - set(categorized_fixtures) -if diff: - for file in diff: - print( - "No category for file %s, add to the corresponding set (BULBS, PLUGS, ..)" - % file - ) - raise Exception("Missing category for %s" % diff) - - -# bulb types -dimmable = parametrize("dimmable", DIMMABLE) -non_dimmable = parametrize("non-dimmable", BULBS - DIMMABLE) -variable_temp = parametrize("variable color temp", VARIABLE_TEMP) -non_variable_temp = parametrize("non-variable color temp", BULBS - VARIABLE_TEMP) -color_bulb = parametrize("color bulbs", COLOR_BULBS) -non_color_bulb = parametrize("non-color bulbs", BULBS - COLOR_BULBS) - -# Parametrize tests to run with device both on and off -turn_on = pytest.mark.parametrize("turn_on", [True, False]) - - -async def handle_turn_on(dev, turn_on): - if turn_on: - await dev.turn_on() - else: - await dev.turn_off() - - -# to avoid adding this for each async function separately -pytestmark = pytest.mark.asyncio - - -def device_for_file(model): - for d in STRIPS: - if d in model: - return SmartStrip - - for d in PLUGS: - if d in model: - return SmartPlug - - # Light strips are recognized also as bulbs, so this has to go first - for d in LIGHT_STRIPS: - if d in model: - return SmartLightStrip - - for d in BULBS: - if d in model: - return SmartBulb - - for d in DIMMERS: - if d in model: - return SmartDimmer - - raise Exception("Unable to find type for %s", model) - - -def get_device_for_file(file): - # if the wanted file is not an absolute path, prepend the fixtures directory - p = Path(file) - if not p.is_absolute(): - p = Path(__file__).parent / "fixtures" / file - - with open(p) as f: - sysinfo = json.load(f) - model = basename(file) - p = device_for_file(model)(host="123.123.123.123") - p.protocol = FakeTransportProtocol(sysinfo) - asyncio.run(p.update()) - return p - - -@pytest.fixture(params=SUPPORTED_DEVICES) -def dev(request): - """Device fixture. - - Provides a device (given --ip) or parametrized fixture for the supported devices. - The initial update is called automatically before returning the device. - """ - file = request.param - - ip = request.config.getoption("--ip") - if ip: - d = asyncio.run(Discover.discover_single(ip)) - asyncio.run(d.update()) - if d.model in file: - return d - raise Exception("Unable to find type for %s" % ip) - - return get_device_for_file(file) - - -def pytest_addoption(parser): - parser.addoption("--ip", action="store", default=None, help="run against device") - - -def pytest_collection_modifyitems(config, items): - if not config.getoption("--ip"): - print("Testing against fixtures.") - return - else: - print("Running against ip %s" % config.getoption("--ip")) - - -# allow mocks to be awaited -# https://stackoverflow.com/questions/51394411/python-object-magicmock-cant-be-used-in-await-expression/51399767#51399767 - - -async def async_magic(): - pass - - -MagicMock.__await__ = lambda x: async_magic().__await__() diff --git a/kasa/tests/fixtures/HS100(US)_1.0.json b/kasa/tests/fixtures/HS100(US)_1.0.json deleted file mode 100644 index cf9b0ba0d..000000000 --- a/kasa/tests/fixtures/HS100(US)_1.0.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "emeter": { - "get_realtime": { - "err_code": -1, - "err_msg": "module not support" - } - }, - "smartlife.iot.common.emeter": { - "err_code": -1, - "err_msg": "module not support" - }, - "smartlife.iot.dimmer": { - "err_code": -1, - "err_msg": "module not support" - }, - "smartlife.iot.smartbulb.lightingservice": { - "err_code": -1, - "err_msg": "module not support" - }, - "system": { - "get_sysinfo": { - "active_mode": "schedule", - "alias": "Mock hs100", - "dev_name": "Wi-Fi Smart Plug", - "deviceId": "048F069965D3230FD1382F0B78EAE68D42CAA2DE", - "err_code": 0, - "feature": "TIM", - "hwId": "92688D028799C60F926049D1C9EFD9E8", - "hw_ver": "1.0", - "icon_hash": "", - "latitude": 63.5442, - "latitude_i": 63.5442, - "led_off": 0, - "longitude": -148.2817, - "longitude_i": -148.2817, - "mac": "50:c7:bf:a3:71:c0", - "model": "HS100(US)", - "oemId": "149C8A24AA3A1445DE84F00DFB210D60", - "on_time": 0, - "relay_state": 0, - "rssi": -65, - "sw_ver": "1.2.5 Build 171129 Rel.174814", - "type": "IOT.SMARTPLUGSWITCH", - "updating": 0 - } - } -} diff --git a/kasa/tests/fixtures/HS105(US)_1.0.json b/kasa/tests/fixtures/HS105(US)_1.0.json deleted file mode 100644 index cf2aa5f47..000000000 --- a/kasa/tests/fixtures/HS105(US)_1.0.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "emeter": { - "get_realtime": { - "err_code": -1, - "err_msg": "module not support" - } - }, - "smartlife.iot.common.emeter": { - "err_code": -1, - "err_msg": "module not support" - }, - "smartlife.iot.dimmer": { - "err_code": -1, - "err_msg": "module not support" - }, - "smartlife.iot.smartbulb.lightingservice": { - "err_code": -1, - "err_msg": "module not support" - }, - "system": { - "get_sysinfo": { - "active_mode": "schedule", - "alias": "Mock hs105", - "dev_name": "Smart Wi-Fi Plug Mini", - "deviceId": "F0723FAFC1FA27FC755B9F228A2297D921FEBCD1", - "err_code": 0, - "feature": "TIM", - "hwId": "51E17031929D5FEF9147091AD67B954A", - "hw_ver": "1.0", - "icon_hash": "", - "INVALIDlatitude": 79.7779, - "latitude_i": 79.7779, - "led_off": 0, - "INVALIDlongitude": 90.8844, - "longitude_i": 90.8844, - "mac": "50:c7:bf:ac:c0:6a", - "model": "HS105(US)", - "oemId": "990ADB7AEDE871C41D1B7613D1FE7A76", - "on_time": 0, - "relay_state": 0, - "rssi": -65, - "sw_ver": "1.2.9 Build 170808 Rel.145916", - "type": "IOT.SMARTPLUGSWITCH", - "updating": 0 - } - } -} diff --git a/kasa/tests/fixtures/HS110(EU)_1.0_real.json b/kasa/tests/fixtures/HS110(EU)_1.0_real.json deleted file mode 100644 index 1a8ced8f5..000000000 --- a/kasa/tests/fixtures/HS110(EU)_1.0_real.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "emeter": { - "get_realtime": { - "current": 0.015342, - "err_code": 0, - "power": 0.983971, - "total": 32.448, - "voltage": 235.595234 - } - }, - "smartlife.iot.common.emeter": { - "err_code": -1, - "err_msg": "module not support" - }, - "smartlife.iot.dimmer": { - "err_code": -1, - "err_msg": "module not support" - }, - "smartlife.iot.smartbulb.lightingservice": { - "err_code": -1, - "err_msg": "module not support" - }, - "system": { - "get_sysinfo": { - "active_mode": "schedule", - "alias": "Kitchen", - "dev_name": "Wi-Fi Smart Plug With Energy Monitoring", - "deviceId": "8006588E50AD389303FF31AB6302907A17442F16", - "err_code": 0, - "feature": "TIM:ENE", - "fwId": "00000000000000000000000000000000", - "hwId": "45E29DA8382494D2E82688B52A0B2EB5", - "hw_ver": "1.0", - "icon_hash": "", - "latitude": 51.476938, - "led_off": 1, - "longitude": 7.216849, - "mac": "50:C7:BF:01:F8:CD", - "model": "HS110(EU)", - "oemId": "3D341ECE302C0642C99E31CE2430544B", - "on_time": 512874, - "relay_state": 1, - "rssi": -71, - "sw_ver": "1.2.5 Build 171213 Rel.101523", - "type": "IOT.SMARTPLUGSWITCH", - "updating": 0 - } - } -} diff --git a/kasa/tests/fixtures/HS110(EU)_2.0.json b/kasa/tests/fixtures/HS110(EU)_2.0.json deleted file mode 100644 index 9fa57d3b5..000000000 --- a/kasa/tests/fixtures/HS110(EU)_2.0.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "emeter": { - "get_realtime": { - "current_ma": 125, - "err_code": 0, - "power_mw": 3140, - "total_wh": 51493, - "voltage_mv": 122049 - } - }, - "smartlife.iot.common.emeter": { - "err_code": -1, - "err_msg": "module not support" - }, - "smartlife.iot.dimmer": { - "err_code": -1, - "err_msg": "module not support" - }, - "smartlife.iot.smartbulb.lightingservice": { - "err_code": -1, - "err_msg": "module not support" - }, - "system": { - "get_sysinfo": { - "active_mode": "schedule", - "alias": "Mock hs110v2", - "dev_name": "Smart Wi-Fi Plug With Energy Monitoring", - "deviceId": "A466BCDB5026318939145B7CC7EF18D8C1D3A954", - "err_code": 0, - "feature": "TIM:ENE", - "hwId": "1F7FABB46373CA51E3AFDE5930ECBB36", - "hw_ver": "2.0", - "icon_hash": "", - "INVALIDlatitude": -60.4599, - "latitude_i": -60.4599, - "led_off": 0, - "INVALIDlongitude": 76.1249, - "longitude_i": 76.1249, - "mac": "50:c7:bf:b9:40:08", - "model": "HS110(EU)", - "oemId": "BB668B949FA4559655F1187DD56622BD", - "on_time": 0, - "relay_state": 0, - "rssi": -65, - "sw_ver": "1.5.2 Build 180130 Rel.085820", - "type": "IOT.SMARTPLUGSWITCH", - "updating": 0 - } - } -} diff --git a/kasa/tests/fixtures/HS110(US)_1.0.json b/kasa/tests/fixtures/HS110(US)_1.0.json deleted file mode 100644 index 562b28a32..000000000 --- a/kasa/tests/fixtures/HS110(US)_1.0.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "emeter": { - "get_realtime": { - "current": 0.1256, - "err_code": 0, - "power": 3.14, - "total": 51.493, - "voltage": 122.049119 - } - }, - "smartlife.iot.common.emeter": { - "err_code": -1, - "err_msg": "module not support" - }, - "smartlife.iot.dimmer": { - "err_code": -1, - "err_msg": "module not support" - }, - "smartlife.iot.smartbulb.lightingservice": { - "err_code": -1, - "err_msg": "module not support" - }, - "system": { - "get_sysinfo": { - "active_mode": "schedule", - "alias": "Mock hs110", - "dev_name": "Wi-Fi Smart Plug With Energy Monitoring", - "deviceId": "11A5FD4A0FA1FCE5468F55D23CE77D1753A93E11", - "err_code": 0, - "feature": "TIM:ENE", - "hwId": "6C56A17315351DD0EDE0BDB1D9EBBD66", - "hw_ver": "1.0", - "icon_hash": "", - "latitude": 82.2866, - "latitude_i": 82.2866, - "led_off": 0, - "longitude": 10.0036, - "longitude_i": 10.0036, - "mac": "50:c7:bf:66:29:29", - "model": "HS110(US)", - "oemId": "F7DFC14D43DA806B55DB66D21F212B60", - "on_time": 0, - "relay_state": 0, - "rssi": -65, - "sw_ver": "1.0.8 Build 151113 Rel.24658", - "type": "IOT.SMARTPLUGSWITCH", - "updating": 0 - } - } -} diff --git a/kasa/tests/fixtures/HS200(US)_1.0.json b/kasa/tests/fixtures/HS200(US)_1.0.json deleted file mode 100644 index 5e9e8b8e9..000000000 --- a/kasa/tests/fixtures/HS200(US)_1.0.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "emeter": { - "get_realtime": { - "err_code": -1, - "err_msg": "module not support" - } - }, - "smartlife.iot.common.emeter": { - "err_code": -1, - "err_msg": "module not support" - }, - "smartlife.iot.dimmer": { - "err_code": -1, - "err_msg": "module not support" - }, - "smartlife.iot.smartbulb.lightingservice": { - "err_code": -1, - "err_msg": "module not support" - }, - "system": { - "get_sysinfo": { - "active_mode": "schedule", - "alias": "Mock hs200", - "dev_name": "Wi-Fi Smart Light Switch", - "deviceId": "EC565185337CF59A4C9A73442AAD5F11C6E91716", - "err_code": 0, - "feature": "TIM", - "hwId": "4B5DB5E42F13728107D075EF5C3ECFA1", - "hw_ver": "1.0", - "icon_hash": "", - "latitude": 58.7882, - "latitude_i": 58.7882, - "led_off": 0, - "longitude": 107.7225, - "longitude_i": 107.7225, - "mac": "50:c7:bf:95:4b:45", - "model": "HS200(US)", - "oemId": "D2A5D690B25980755216FD684AF8CD88", - "on_time": 0, - "relay_state": 0, - "rssi": -65, - "sw_ver": "1.1.0 Build 160521 Rel.085826", - "type": "IOT.SMARTPLUGSWITCH", - "updating": 0 - } - } -} diff --git a/kasa/tests/fixtures/HS300(US)_1.0.json b/kasa/tests/fixtures/HS300(US)_1.0.json deleted file mode 100644 index 997919c17..000000000 --- a/kasa/tests/fixtures/HS300(US)_1.0.json +++ /dev/null @@ -1,102 +0,0 @@ -{ - "emeter": { - "get_realtime": { - "current_ma": 125, - "err_code": 0, - "power_mw": 3140, - "total_wh": 51493, - "voltage_mv": 122049 - } - }, - "smartlife.iot.common.emeter": { - "err_code": -1, - "err_msg": "module not support" - }, - "smartlife.iot.dimmer": { - "err_code": -1, - "err_msg": "module not support" - }, - "smartlife.iot.smartbulb.lightingservice": { - "err_code": -1, - "err_msg": "module not support" - }, - "system": { - "get_sysinfo": { - "alias": "Mock hs300", - "child_num": 6, - "children": [ - { - "alias": "Mock One", - "id": "00", - "next_action": { - "type": -1 - }, - "on_time": 123, - "state": 1 - }, - { - "alias": "Mock Two", - "id": "01", - "next_action": { - "type": -1 - }, - "on_time": 333, - "state": 1 - }, - { - "alias": "Mock Three", - "id": "02", - "next_action": { - "type": -1 - }, - "on_time": 0, - "state": 0 - }, - { - "alias": "Mock Four", - "id": "03", - "next_action": { - "type": -1 - }, - "on_time": 0, - "state": 0 - }, - { - "alias": "Mock Five", - "id": "04", - "next_action": { - "type": -1 - }, - "on_time": 0, - "state": 0 - }, - { - "alias": "Mock Six", - "id": "05", - "next_action": { - "type": -1 - }, - "on_time": 0, - "state": 0 - } - ], - "deviceId": "4BFC2F2C8678FE623700FD3737EC4E245196F3CF", - "err_code": 0, - "feature": "TIM:ENE", - "hwId": "1B63E5DF21B5AFB52F364DE66BFAAF8A", - "hw_ver": "1.0", - "latitude": -68.9980, - "latitude_i": -68.9980, - "led_off": 0, - "longitude": -109.4400, - "longitude_i": -109.4400, - "mac": "50:c7:bf:c2:75:88", - "mic_type": "IOT.SMARTPLUGSWITCH", - "model": "HS300(US)", - "oemId": "FC71DAAB004326F9369EDEF4353E4FE1", - "rssi": -68, - "sw_ver": "1.0.6 Build 180627 Rel.081000", - "updating": 0 - } - } -} diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py deleted file mode 100644 index 55c3e00cb..000000000 --- a/kasa/tests/newfakes.py +++ /dev/null @@ -1,463 +0,0 @@ -import logging -import re - -from voluptuous import ( # type: ignore - REMOVE_EXTRA, - All, - Any, - Coerce, - Invalid, - Optional, - Range, - Schema, -) - -from ..protocol import TPLinkSmartHomeProtocol - -_LOGGER = logging.getLogger(__name__) - - -def check_int_bool(x): - if x != 0 and x != 1: - raise Invalid(x) - return x - - -def check_mac(x): - if re.match("[0-9a-f]{2}([-:])[0-9a-f]{2}(\\1[0-9a-f]{2}){4}$", x.lower()): - return x - raise Invalid(x) - - -def check_mode(x): - if x in ["schedule", "none", "count_down"]: - return x - - raise Invalid(f"invalid mode {x}") - - -def lb_dev_state(x): - if x in ["normal"]: - return x - - raise Invalid(f"Invalid dev_state {x}") - - -TZ_SCHEMA = Schema( - {"zone_str": str, "dst_offset": int, "index": All(int, Range(min=0)), "tz_str": str} -) - -CURRENT_CONSUMPTION_SCHEMA = Schema( - Any( - { - "voltage": Any(All(float, Range(min=0, max=300)), None), - "power": Any(Coerce(float, Range(min=0)), None), - "total": Any(Coerce(float, Range(min=0)), None), - "current": Any(All(float, Range(min=0)), None), - "voltage_mv": Any( - All(float, Range(min=0, max=300000)), int, None - ), # TODO can this be int? - "power_mw": Any(Coerce(float, Range(min=0)), None), - "total_wh": Any(Coerce(float, Range(min=0)), None), - "current_ma": Any( - All(float, Range(min=0)), int, None - ), # TODO can this be int? - }, - None, - ) -) - -# these schemas should go to the mainlib as -# they can be useful when adding support for new features/devices -# as well as to check that faked devices are operating properly. -PLUG_SCHEMA = Schema( - { - "active_mode": check_mode, - "alias": str, - "dev_name": str, - "deviceId": str, - "feature": str, - "fwId": str, - "hwId": str, - "hw_ver": str, - "icon_hash": str, - "led_off": check_int_bool, - "latitude": Any(All(float, Range(min=-90, max=90)), 0, None), - "latitude_i": Any(All(float, Range(min=-90, max=90)), 0, None), - "longitude": Any(All(float, Range(min=-180, max=180)), 0, None), - "longitude_i": Any(All(float, Range(min=-180, max=180)), 0, None), - "mac": check_mac, - "model": str, - "oemId": str, - "on_time": int, - "relay_state": int, - "rssi": Any(int, None), # rssi can also be positive, see #54 - "sw_ver": str, - "type": str, - "mic_type": str, - "updating": check_int_bool, - # these are available on hs220 - "brightness": int, - "preferred_state": [ - {"brightness": All(int, Range(min=0, max=100)), "index": int} - ], - "next_action": {"type": int}, - "child_num": Optional(Any(None, int)), # TODO fix hs300 checks - "children": Optional(list), # TODO fix hs300 - # TODO some tplink simulator entries contain invalid (mic_mac, _i variants for lat/lon) - # Therefore we add REMOVE_EXTRA.. - # "INVALIDmac": Optional, - # "INVALIDlatitude": Optional, - # "INVALIDlongitude": Optional, - }, - extra=REMOVE_EXTRA, -) - -LIGHT_STATE_SCHEMA = Schema( - { - "brightness": All(int, Range(min=0, max=100)), - "color_temp": int, - "hue": All(int, Range(min=0, max=255)), - "mode": str, - "on_off": check_int_bool, - "saturation": All(int, Range(min=0, max=255)), - "dft_on_state": Optional( - { - "brightness": All(int, Range(min=0, max=100)), - "color_temp": All(int, Range(min=2000, max=9000)), - "hue": All(int, Range(min=0, max=255)), - "mode": str, - "saturation": All(int, Range(min=0, max=255)), - } - ), - "err_code": int, - } -) - -BULB_SCHEMA = PLUG_SCHEMA.extend( - { - "ctrl_protocols": Optional(dict), - "description": Optional(str), # TODO: LBxxx similar to dev_name - "dev_state": lb_dev_state, - "disco_ver": str, - "heapsize": int, - "is_color": check_int_bool, - "is_dimmable": check_int_bool, - "is_factory": bool, - "is_variable_color_temp": check_int_bool, - "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=255)), - "index": int, - "saturation": All(int, Range(min=0, max=255)), - } - ], - } -) - - -def get_realtime(obj, x, *args): - return { - "current": 0.268587, - "voltage": 125.836131, - "power": 33.495623, - "total": 0.199000, - } - - -def get_monthstat(obj, x, *args): - if x["year"] < 2016: - return {"month_list": []} - - return { - "month_list": [ - {"year": 2016, "month": 11, "energy": 1.089000}, - {"year": 2016, "month": 12, "energy": 1.582000}, - ] - } - - -def get_daystat(obj, x, *args): - if x["year"] < 2016: - return {"day_list": []} - - return { - "day_list": [ - {"year": 2016, "month": 11, "day": 24, "energy": 0.026000}, - {"year": 2016, "month": 11, "day": 25, "energy": 0.109000}, - ] - } - - -emeter_support = { - "get_realtime": get_realtime, - "get_monthstat": get_monthstat, - "get_daystat": get_daystat, -} - - -def get_realtime_units(obj, x, *args): - return {"power_mw": 10800} - - -def get_monthstat_units(obj, x, *args): - if x["year"] < 2016: - return {"month_list": []} - - return { - "month_list": [ - {"year": 2016, "month": 11, "energy_wh": 32}, - {"year": 2016, "month": 12, "energy_wh": 16}, - ] - } - - -def get_daystat_units(obj, x, *args): - if x["year"] < 2016: - return {"day_list": []} - - return { - "day_list": [ - {"year": 2016, "month": 11, "day": 24, "energy_wh": 20}, - {"year": 2016, "month": 11, "day": 25, "energy_wh": 32}, - ] - } - - -emeter_units_support = { - "get_realtime": get_realtime_units, - "get_monthstat": get_monthstat_units, - "get_daystat": get_daystat_units, -} - - -emeter_commands = { - "emeter": emeter_support, - "smartlife.iot.common.emeter": emeter_units_support, -} - - -def error(msg="default msg"): - return {"err_code": -1323, "msg": msg} - - -def success(res): - if res: - res.update({"err_code": 0}) - else: - res = {"err_code": 0} - return res - - -class FakeTransportProtocol(TPLinkSmartHomeProtocol): - def __init__(self, info): - self.discovery_data = info - proto = FakeTransportProtocol.baseproto - - for target in info: - # print("target %s" % target) - for cmd in info[target]: - # print("initializing tgt %s cmd %s" % (target, cmd)) - proto[target][cmd] = info[target][cmd] - # if we have emeter support, we need to add the missing pieces - for module in ["emeter", "smartlife.iot.common.emeter"]: - for etype in ["get_realtime", "get_daystat", "get_monthstat"]: - if etype in info[module]: # if the fixture has the data, use it - # print("got %s %s from fixture: %s" % (module, etype, info[module][etype])) - proto[module][etype] = info[module][etype] - else: # otherwise fall back to the static one - dummy_data = emeter_commands[module][etype] - # print("got %s %s from dummy: %s" % (module, etype, dummy_data)) - proto[module][etype] = dummy_data - - # print("initialized: %s" % proto[module]) - - self.proto = proto - - def set_alias(self, x, child_ids=[]): - _LOGGER.debug("Setting alias to %s, child_ids: %s", x["alias"], child_ids) - if child_ids: - for child in self.proto["system"]["get_sysinfo"]["children"]: - if child["id"] in child_ids: - child["alias"] = x["alias"] - else: - self.proto["system"]["get_sysinfo"]["alias"] = x["alias"] - - def set_relay_state(self, x, 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"]: - if child["id"] in child_ids: - _LOGGER.info("Found %s, turning to %s", child, x["state"]) - child["state"] = x["state"] - else: - self.proto["system"]["get_sysinfo"]["relay_state"] = x["state"] - - def set_led_off(self, x, *args): - _LOGGER.debug("Setting led off to %s", x) - self.proto["system"]["get_sysinfo"]["led_off"] = x["off"] - - def set_mac(self, x, *args): - _LOGGER.debug("Setting mac to %s", x) - self.proto["system"]["get_sysinfo"]["mac"] = x["mac"] - - def set_hs220_brightness(self, x, *args): - _LOGGER.debug("Setting brightness to %s", x) - self.proto["system"]["get_sysinfo"]["brightness"] = x["brightness"] - - def set_hs220_dimmer_transition(self, x, *args): - _LOGGER.debug("Setting dimmer transition to %s", x) - brightness = x["brightness"] - if brightness == 0: - self.proto["system"]["get_sysinfo"]["relay_state"] = 0 - else: - self.proto["system"]["get_sysinfo"]["relay_state"] = 1 - self.proto["system"]["get_sysinfo"]["brightness"] = x["brightness"] - - def transition_light_state(self, state_changes, *args): - _LOGGER.debug("Setting light state to %s", state_changes) - light_state = self.proto["system"]["get_sysinfo"]["light_state"] - - _LOGGER.debug("Current light state: %s", light_state) - new_state = light_state - - if state_changes["on_off"] == 1: # turn on requested - if not light_state[ - "on_off" - ]: # if we were off, use the dft_on_state as a base - _LOGGER.debug("Bulb was off, using dft_on_state") - new_state = light_state["dft_on_state"] - - # override the existing settings - new_state.update(state_changes) - - if ( - not state_changes["on_off"] and "dft_on_state" not in light_state - ): # if not already off, pack the data inside dft_on_state - _LOGGER.debug( - "Bulb was on and turn_off was requested, saving to dft_on_state" - ) - new_state = {"dft_on_state": light_state, "on_off": 0} - - _LOGGER.debug("New light state: %s", new_state) - self.proto["system"]["get_sysinfo"]["light_state"] = new_state - - def light_state(self, x, *args): - light_state = self.proto["system"]["get_sysinfo"]["light_state"] - # Our tests have light state off, so we simply return the dft_on_state when device is on. - _LOGGER.debug("reporting light state: %s", light_state) - # TODO: hack to go around KL430 fixture differences - if light_state["on_off"] and "dft_on_state" in light_state: - return light_state["dft_on_state"] - else: - return light_state - - baseproto = { - "system": { - "set_relay_state": set_relay_state, - "set_dev_alias": set_alias, - "set_led_off": set_led_off, - "get_dev_icon": {"icon": None, "hash": None}, - "set_mac_addr": set_mac, - "get_sysinfo": None, - }, - "emeter": { - "get_realtime": None, - "get_daystat": None, - "get_monthstat": None, - "erase_emeter_state": None, - }, - "smartlife.iot.common.emeter": { - "get_realtime": None, - "get_daystat": None, - "get_monthstat": None, - "erase_emeter_state": None, - }, - "smartlife.iot.smartbulb.lightingservice": { - "get_light_state": light_state, - "transition_light_state": transition_light_state, - }, - # lightstrip follows the same payloads but uses different module & method - "smartlife.iot.lightStrip": { - "set_light_state": transition_light_state, - "get_light_state": light_state, - }, - "time": { - "get_time": { - "year": 2017, - "month": 1, - "mday": 2, - "hour": 3, - "min": 4, - "sec": 5, - }, - "get_timezone": { - "zone_str": "test", - "dst_offset": -1, - "index": 12, - "tz_str": "test2", - }, - "set_timezone": None, - }, - # HS220 brightness, different setter and getter - "smartlife.iot.dimmer": { - "set_brightness": set_hs220_brightness, - "set_dimmer_transition": set_hs220_dimmer_transition, - }, - } - - async def query(self, host, request, port=9999): - proto = self.proto - - # collect child ids from context - try: - child_ids = request["context"]["child_ids"] - request.pop("context", None) - except KeyError: - child_ids = [] - - def get_response_for_module(target): - - if target not in proto.keys(): - return error(msg="target not found") - - def get_response_for_command(cmd): - if cmd not in proto[target].keys(): - return error(msg=f"command {cmd} not found") - - params = request[target][cmd] - _LOGGER.debug(f"Going to execute {target}.{cmd} (params: {params}).. ") - - if callable(proto[target][cmd]): - res = proto[target][cmd](self, params, child_ids) - _LOGGER.debug("[callable] %s.%s: %s", target, cmd, res) - return success(res) - elif isinstance(proto[target][cmd], dict): - res = proto[target][cmd] - _LOGGER.debug("[static] %s.%s: %s", target, cmd, res) - return success(res) - else: - raise NotImplementedError(f"target {target} cmd {cmd}") - - from collections import defaultdict - - cmd_responses = defaultdict(dict) - for cmd in request[target]: - cmd_responses[target][cmd] = get_response_for_command(cmd) - - return cmd_responses - - response = {} - for target in request: - response.update(get_response_for_module(target)) - - return response diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py deleted file mode 100644 index 7d6e45e02..000000000 --- a/kasa/tests/test_bulb.py +++ /dev/null @@ -1,229 +0,0 @@ -import pytest - -from kasa import DeviceType, SmartDeviceException - -from .conftest import ( - bulb, - color_bulb, - dimmable, - handle_turn_on, - non_color_bulb, - non_dimmable, - non_variable_temp, - pytestmark, - turn_on, - variable_temp, -) -from .newfakes import BULB_SCHEMA, LIGHT_STATE_SCHEMA - - -@bulb -async def test_bulb_sysinfo(dev): - assert dev.sys_info is not None - BULB_SCHEMA(dev.sys_info) - - assert dev.model is not None - - # TODO: remove special handling for lightstrip - if not dev.is_light_strip: - assert dev.device_type == DeviceType.Bulb - assert dev.is_bulb - - -@bulb -async def test_state_attributes(dev): - assert "Brightness" in dev.state_information - assert dev.state_information["Brightness"] == dev.brightness - - assert "Is dimmable" in dev.state_information - assert dev.state_information["Is dimmable"] == dev.is_dimmable - - -@bulb -async def test_light_state_without_update(dev, monkeypatch): - with pytest.raises(SmartDeviceException): - monkeypatch.setitem( - dev._last_update["system"]["get_sysinfo"], "light_state", None - ) - print(dev.light_state) - - -@bulb -async def test_get_light_state(dev): - LIGHT_STATE_SCHEMA(await dev.get_light_state()) - - -@color_bulb -@turn_on -async def test_hsv(dev, turn_on): - await handle_turn_on(dev, turn_on) - assert dev.is_color - - hue, saturation, brightness = dev.hsv - assert 0 <= hue <= 255 - assert 0 <= saturation <= 100 - assert 0 <= brightness <= 100 - - await dev.set_hsv(hue=1, saturation=1, value=1) - - hue, saturation, brightness = dev.hsv - assert hue == 1 - assert saturation == 1 - assert brightness == 1 - - -@color_bulb -async def test_set_hsv_transition(dev, mocker): - set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") - await dev.set_hsv(10, 10, 100, transition=1000) - - set_light_state.assert_called_with( - {"hue": 10, "saturation": 10, "brightness": 100, "color_temp": 0}, - transition=1000, - ) - - -@color_bulb -@turn_on -async def test_invalid_hsv(dev, turn_on): - await handle_turn_on(dev, turn_on) - assert dev.is_color - - for invalid_hue in [-1, 361, 0.5]: - with pytest.raises(ValueError): - await dev.set_hsv(invalid_hue, 0, 0) - - for invalid_saturation in [-1, 101, 0.5]: - with pytest.raises(ValueError): - await dev.set_hsv(0, invalid_saturation, 0) - - for invalid_brightness in [-1, 101, 0.5]: - with pytest.raises(ValueError): - await dev.set_hsv(0, 0, invalid_brightness) - - -@color_bulb -async def test_color_state_information(dev): - assert "HSV" in dev.state_information - assert dev.state_information["HSV"] == dev.hsv - - -@non_color_bulb -async def test_hsv_on_non_color(dev): - assert not dev.is_color - - with pytest.raises(SmartDeviceException): - await dev.set_hsv(0, 0, 0) - with pytest.raises(SmartDeviceException): - print(dev.hsv) - - -@variable_temp -async def test_variable_temp_state_information(dev): - assert "Color temperature" in dev.state_information - assert dev.state_information["Color temperature"] == dev.color_temp - - assert "Valid temperature range" in dev.state_information - assert ( - dev.state_information["Valid temperature range"] == dev.valid_temperature_range - ) - - -@variable_temp -@turn_on -async def test_try_set_colortemp(dev, turn_on): - await handle_turn_on(dev, turn_on) - await dev.set_color_temp(2700) - assert dev.color_temp == 2700 - - -@variable_temp -async def test_set_color_temp_transition(dev, mocker): - set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") - await dev.set_color_temp(2700, transition=100) - - set_light_state.assert_called_with({"color_temp": 2700}, transition=100) - - -@variable_temp -async def test_unknown_temp_range(dev, monkeypatch): - with pytest.raises(SmartDeviceException): - monkeypatch.setitem(dev._sys_info, "model", "unknown bulb") - dev.valid_temperature_range() - - -@variable_temp -async def test_out_of_range_temperature(dev): - with pytest.raises(ValueError): - await dev.set_color_temp(1000) - with pytest.raises(ValueError): - await dev.set_color_temp(10000) - - -@non_variable_temp -async def test_non_variable_temp(dev): - with pytest.raises(SmartDeviceException): - await dev.set_color_temp(2700) - - with pytest.raises(SmartDeviceException): - dev.valid_temperature_range() - - with pytest.raises(SmartDeviceException): - print(dev.color_temp) - - -@dimmable -@turn_on -async def test_dimmable_brightness(dev, turn_on): - await handle_turn_on(dev, turn_on) - assert dev.is_dimmable - - await dev.set_brightness(50) - assert dev.brightness == 50 - - await dev.set_brightness(10) - assert dev.brightness == 10 - - with pytest.raises(ValueError): - await dev.set_brightness("foo") - - -@bulb -async def test_turn_on_transition(dev, mocker): - set_light_state = mocker.patch("kasa.SmartBulb.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 -async def test_dimmable_brightness_transition(dev, mocker): - set_light_state = mocker.patch("kasa.SmartBulb.set_light_state") - await dev.set_brightness(10, transition=1000) - - set_light_state.assert_called_with({"brightness": 10}, transition=1000) - - -@dimmable -async def test_invalid_brightness(dev): - assert dev.is_dimmable - - with pytest.raises(ValueError): - await dev.set_brightness(110) - - with pytest.raises(ValueError): - await dev.set_brightness(-100) - - -@non_dimmable -async def test_non_dimmable(dev): - assert not dev.is_dimmable - - with pytest.raises(SmartDeviceException): - assert dev.brightness == 0 - with pytest.raises(SmartDeviceException): - await dev.set_brightness(100) diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py deleted file mode 100644 index 1b94d4897..000000000 --- a/kasa/tests/test_cli.py +++ /dev/null @@ -1,114 +0,0 @@ -from asyncclick.testing import CliRunner - -from kasa import SmartDevice -from kasa.cli import alias, brightness, emeter, raw_command, state, sysinfo - -from .conftest import handle_turn_on, pytestmark, turn_on - - -async def test_sysinfo(dev): - runner = CliRunner() - res = await runner.invoke(sysinfo, obj=dev) - assert "System info" in res.output - assert dev.alias in res.output - - -@turn_on -async def test_state(dev, turn_on): - await handle_turn_on(dev, turn_on) - runner = CliRunner() - res = await runner.invoke(state, obj=dev) - print(res.output) - - if dev.is_on: - assert "Device state: ON" in res.output - else: - assert "Device state: OFF" in res.output - - -async def test_alias(dev): - runner = CliRunner() - - res = await runner.invoke(alias, obj=dev) - assert f"Alias: {dev.alias}" in res.output - - new_alias = "new alias" - res = await runner.invoke(alias, [new_alias], obj=dev) - assert f"Setting alias to {new_alias}" in res.output - - res = await runner.invoke(alias, obj=dev) - assert f"Alias: {new_alias}" in res.output - - -async def test_raw_command(dev): - runner = CliRunner() - res = await runner.invoke(raw_command, ["system", "get_sysinfo"], obj=dev) - - assert res.exit_code == 0 - assert dev.alias in res.output - - res = await runner.invoke(raw_command, obj=dev) - assert res.exit_code != 0 - assert "Usage" in res.output - - -async def test_emeter(dev: SmartDevice, mocker): - runner = CliRunner() - - res = await runner.invoke(emeter, obj=dev) - if not dev.has_emeter: - assert "Device has no emeter" in res.output - return - - assert "== Emeter ==" in res.output - - monthly = mocker.patch.object(dev, "get_emeter_monthly") - res = await runner.invoke(emeter, ["--year", "1900"], obj=dev) - assert "For year" in res.output - monthly.assert_called() - - daily = mocker.patch.object(dev, "get_emeter_daily") - res = await runner.invoke(emeter, ["--month", "1900-12"], obj=dev) - assert "For month" in res.output - daily.assert_called() - - -async def test_brightness(dev): - runner = CliRunner() - res = await runner.invoke(brightness, obj=dev) - if not dev.is_dimmable: - assert "This device does not support brightness." in res.output - return - - res = await runner.invoke(brightness, obj=dev) - assert f"Brightness: {dev.brightness}" in res.output - - res = await runner.invoke(brightness, ["12"], obj=dev) - assert "Setting brightness" in res.output - - res = await runner.invoke(brightness, obj=dev) - assert "Brightness: 12" in res.output - - -async def test_temperature(dev): - pass - - -async def test_hsv(dev): - pass - - -async def test_led(dev): - pass - - -async def test_on(dev): - pass - - -async def test_off(dev): - pass - - -async def test_reboot(dev): - pass diff --git a/kasa/tests/test_dimmer.py b/kasa/tests/test_dimmer.py deleted file mode 100644 index 96a1021a6..000000000 --- a/kasa/tests/test_dimmer.py +++ /dev/null @@ -1,134 +0,0 @@ -import pytest - -from kasa import SmartDimmer - -from .conftest import dimmer, handle_turn_on, pytestmark, turn_on - - -@dimmer -@turn_on -async def test_set_brightness(dev, turn_on): - await handle_turn_on(dev, turn_on) - - await dev.set_brightness(99) - assert dev.brightness == 99 - assert dev.is_on == turn_on - - await dev.set_brightness(0) - assert dev.brightness == 1 - assert dev.is_on == turn_on - - -@dimmer -@turn_on -async def test_set_brightness_transition(dev, turn_on, mocker): - await handle_turn_on(dev, turn_on) - query_helper = mocker.spy(SmartDimmer, "_query_helper") - - await dev.set_brightness(99, transition=1000) - - assert dev.brightness == 99 - assert dev.is_on - query_helper.assert_called_with( - mocker.ANY, - "smartlife.iot.dimmer", - "set_dimmer_transition", - {"brightness": 99, "duration": 1000}, - ) - - await dev.set_brightness(0, transition=1000) - assert dev.brightness == 1 - - -@dimmer -async def test_set_brightness_invalid(dev): - for invalid_brightness in [-1, 101, 0.5]: - with pytest.raises(ValueError): - await dev.set_brightness(invalid_brightness) - - for invalid_transition in [-1, 0, 0.5]: - with pytest.raises(ValueError): - await dev.set_brightness(1, transition=invalid_transition) - - -@dimmer -async def test_turn_on_transition(dev, mocker): - query_helper = mocker.spy(SmartDimmer, "_query_helper") - original_brightness = dev.brightness - - await dev.turn_on(transition=1000) - - assert dev.is_on - assert dev.brightness == original_brightness - query_helper.assert_called_with( - mocker.ANY, - "smartlife.iot.dimmer", - "set_dimmer_transition", - {"brightness": original_brightness, "duration": 1000}, - ) - - -@dimmer -async def test_turn_off_transition(dev, mocker): - await handle_turn_on(dev, True) - query_helper = mocker.spy(SmartDimmer, "_query_helper") - original_brightness = dev.brightness - - await dev.turn_off(transition=1000) - - assert dev.is_off - assert dev.brightness == original_brightness - query_helper.assert_called_with( - mocker.ANY, - "smartlife.iot.dimmer", - "set_dimmer_transition", - {"brightness": 0, "duration": 1000}, - ) - - -@dimmer -@turn_on -async def test_set_dimmer_transition(dev, turn_on, mocker): - await handle_turn_on(dev, turn_on) - query_helper = mocker.spy(SmartDimmer, "_query_helper") - - await dev.set_dimmer_transition(99, 1000) - - assert dev.is_on - assert dev.brightness == 99 - query_helper.assert_called_with( - mocker.ANY, - "smartlife.iot.dimmer", - "set_dimmer_transition", - {"brightness": 99, "duration": 1000}, - ) - - -@dimmer -@turn_on -async def test_set_dimmer_transition_to_off(dev, turn_on, mocker): - await handle_turn_on(dev, turn_on) - original_brightness = dev.brightness - query_helper = mocker.spy(SmartDimmer, "_query_helper") - - await dev.set_dimmer_transition(0, 1000) - - assert dev.is_off - assert dev.brightness == original_brightness - query_helper.assert_called_with( - mocker.ANY, - "smartlife.iot.dimmer", - "set_dimmer_transition", - {"brightness": 0, "duration": 1000}, - ) - - -@dimmer -async def test_set_dimmer_transition_invalid(dev): - for invalid_brightness in [-1, 101, 0.5]: - with pytest.raises(ValueError): - await dev.set_dimmer_transition(invalid_brightness, 1000) - - for invalid_transition in [-1, 0, 0.5]: - with pytest.raises(ValueError): - await dev.set_dimmer_transition(1, invalid_transition) diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py deleted file mode 100644 index 529ad8d63..000000000 --- a/kasa/tests/test_discovery.py +++ /dev/null @@ -1,49 +0,0 @@ -# type: ignore -import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 - -from kasa import DeviceType, Discover, SmartDevice, SmartDeviceException - -from .conftest import bulb, dimmer, lightstrip, plug, pytestmark, strip - - -@plug -async def test_type_detection_plug(dev: SmartDevice): - d = Discover._get_device_class(dev.protocol.discovery_data)("localhost") - assert d.is_plug - assert d.device_type == DeviceType.Plug - - -@bulb -async def test_type_detection_bulb(dev: SmartDevice): - d = Discover._get_device_class(dev.protocol.discovery_data)("localhost") - # TODO: light_strip is a special case for now to force bulb tests on it - if not d.is_light_strip: - assert d.is_bulb - assert d.device_type == DeviceType.Bulb - - -@strip -async def test_type_detection_strip(dev: SmartDevice): - d = Discover._get_device_class(dev.protocol.discovery_data)("localhost") - assert d.is_strip - assert d.device_type == DeviceType.Strip - - -@dimmer -async def test_type_detection_dimmer(dev: SmartDevice): - d = Discover._get_device_class(dev.protocol.discovery_data)("localhost") - assert d.is_dimmer - assert d.device_type == DeviceType.Dimmer - - -@lightstrip -async def test_type_detection_lightstrip(dev: SmartDevice): - d = Discover._get_device_class(dev.protocol.discovery_data)("localhost") - assert d.is_light_strip - assert d.device_type == DeviceType.LightStrip - - -async def test_type_unknown(): - invalid_info = {"system": {"get_sysinfo": {"type": "nosuchtype"}}} - with pytest.raises(SmartDeviceException): - Discover._get_device_class(invalid_info) diff --git a/kasa/tests/test_emeter.py b/kasa/tests/test_emeter.py deleted file mode 100644 index 5cdd50677..000000000 --- a/kasa/tests/test_emeter.py +++ /dev/null @@ -1,117 +0,0 @@ -import pytest - -from kasa import SmartDeviceException - -from .conftest import has_emeter, no_emeter, pytestmark -from .newfakes import CURRENT_CONSUMPTION_SCHEMA - - -@no_emeter -async def test_no_emeter(dev): - assert not dev.has_emeter - - with pytest.raises(SmartDeviceException): - await dev.get_emeter_realtime() - with pytest.raises(SmartDeviceException): - await dev.get_emeter_daily() - with pytest.raises(SmartDeviceException): - await dev.get_emeter_monthly() - with pytest.raises(SmartDeviceException): - await dev.erase_emeter_stats() - - -@has_emeter -async def test_get_emeter_realtime(dev): - if dev.is_strip: - pytest.skip("Disabled for strips temporarily") - - assert dev.has_emeter - - current_emeter = await dev.get_emeter_realtime() - CURRENT_CONSUMPTION_SCHEMA(current_emeter) - - -@has_emeter -async def test_get_emeter_daily(dev): - if dev.is_strip: - pytest.skip("Disabled for strips temporarily") - - assert dev.has_emeter - - assert await dev.get_emeter_daily(year=1900, month=1) == {} - - d = await dev.get_emeter_daily() - assert len(d) > 0 - - k, v = d.popitem() - assert isinstance(k, int) - assert isinstance(v, float) - - # Test kwh (energy, energy_wh) - d = await dev.get_emeter_daily(kwh=False) - k2, v2 = d.popitem() - assert v * 1000 == v2 - - -@has_emeter -async def test_get_emeter_monthly(dev): - if dev.is_strip: - pytest.skip("Disabled for strips temporarily") - - assert dev.has_emeter - - assert await dev.get_emeter_monthly(year=1900) == {} - - d = await dev.get_emeter_monthly() - assert len(d) > 0 - - k, v = d.popitem() - assert isinstance(k, int) - assert isinstance(v, float) - - # Test kwh (energy, energy_wh) - d = await dev.get_emeter_monthly(kwh=False) - k2, v2 = d.popitem() - assert v * 1000 == v2 - - -@has_emeter -async def test_emeter_status(dev): - if dev.is_strip: - pytest.skip("Disabled for strips temporarily") - - assert dev.has_emeter - - d = await dev.get_emeter_realtime() - - with pytest.raises(KeyError): - assert d["foo"] - - assert d["power_mw"] == d["power"] * 1000 - # bulbs have only power according to tplink simulator. - if not dev.is_bulb: - assert d["voltage_mv"] == d["voltage"] * 1000 - - assert d["current_ma"] == d["current"] * 1000 - assert d["total_wh"] == d["total"] * 1000 - - -@pytest.mark.skip("not clearing your stats..") -@has_emeter -async def test_erase_emeter_stats(dev): - assert dev.has_emeter - - await dev.erase_emeter() - - -@has_emeter -async def test_current_consumption(dev): - if dev.is_strip: - pytest.skip("Disabled for strips temporarily") - - if dev.has_emeter: - x = await dev.current_consumption() - assert isinstance(x, float) - assert x >= 0.0 - else: - assert await dev.current_consumption() is None diff --git a/kasa/tests/test_plug.py b/kasa/tests/test_plug.py deleted file mode 100644 index a301095e6..000000000 --- a/kasa/tests/test_plug.py +++ /dev/null @@ -1,28 +0,0 @@ -from kasa import DeviceType - -from .conftest import plug, pytestmark -from .newfakes import PLUG_SCHEMA - - -@plug -async def test_plug_sysinfo(dev): - assert dev.sys_info is not None - PLUG_SCHEMA(dev.sys_info) - - assert dev.model is not None - - assert dev.device_type == DeviceType.Plug or dev.device_type == DeviceType.Strip - assert dev.is_plug or dev.is_strip - - -@plug -async def test_led(dev): - original = dev.led - - await dev.set_led(False) - assert not dev.led - - await dev.set_led(True) - assert dev.led - - await dev.set_led(original) diff --git a/kasa/tests/test_protocol.py b/kasa/tests/test_protocol.py deleted file mode 100644 index 51c01d49d..000000000 --- a/kasa/tests/test_protocol.py +++ /dev/null @@ -1,96 +0,0 @@ -import json - -import pytest - -from ..exceptions import SmartDeviceException -from ..protocol import TPLinkSmartHomeProtocol -from .conftest import pytestmark - - -@pytest.mark.parametrize("retry_count", [1, 3, 5]) -async def test_protocol_retries(mocker, retry_count): - def aio_mock_writer(_, __): - reader = mocker.patch("asyncio.StreamReader") - writer = mocker.patch("asyncio.StreamWriter") - - mocker.patch( - "asyncio.StreamWriter.write", side_effect=Exception("dummy exception") - ) - - return reader, writer - - conn = mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) - with pytest.raises(SmartDeviceException): - await TPLinkSmartHomeProtocol.query("127.0.0.1", {}, retry_count=retry_count) - - assert conn.call_count == retry_count + 1 - - -def test_encrypt(): - d = json.dumps({"foo": 1, "bar": 2}) - encrypted = TPLinkSmartHomeProtocol.encrypt(d) - # encrypt adds a 4 byte header - encrypted = encrypted[4:] - assert d == TPLinkSmartHomeProtocol.decrypt(encrypted) - - -def test_encrypt_unicode(): - d = "{'snowman': '\u2603'}" - - e = bytes( - [ - 208, - 247, - 132, - 234, - 133, - 242, - 159, - 254, - 144, - 183, - 141, - 173, - 138, - 104, - 240, - 115, - 84, - 41, - ] - ) - - encrypted = TPLinkSmartHomeProtocol.encrypt(d) - # encrypt adds a 4 byte header - encrypted = encrypted[4:] - - assert e == encrypted - - -def test_decrypt_unicode(): - e = bytes( - [ - 208, - 247, - 132, - 234, - 133, - 242, - 159, - 254, - 144, - 183, - 141, - 173, - 138, - 104, - 240, - 115, - 84, - 41, - ] - ) - - d = "{'snowman': '\u2603'}" - - assert d == TPLinkSmartHomeProtocol.decrypt(e) diff --git a/kasa/tests/test_readme_examples.py b/kasa/tests/test_readme_examples.py deleted file mode 100644 index c4d9f693e..000000000 --- a/kasa/tests/test_readme_examples.py +++ /dev/null @@ -1,74 +0,0 @@ -import sys - -import pytest - -import xdoctest -from kasa.tests.conftest import get_device_for_file - - -def test_bulb_examples(mocker): - """Use KL130 (bulb with all features) to test the doctests.""" - p = get_device_for_file("KL130(US)_1.0.json") - mocker.patch("kasa.smartbulb.SmartBulb", return_value=p) - mocker.patch("kasa.smartbulb.SmartBulb.update") - res = xdoctest.doctest_module("kasa.smartbulb", "all") - assert not res["failed"] - - -def test_smartdevice_examples(mocker): - """Use HS110 for emeter examples.""" - p = get_device_for_file("HS110(EU)_1.0_real.json") - mocker.patch("kasa.smartdevice.SmartDevice", return_value=p) - mocker.patch("kasa.smartdevice.SmartDevice.update") - res = xdoctest.doctest_module("kasa.smartdevice", "all") - assert not res["failed"] - - -def test_plug_examples(mocker): - """Test plug examples.""" - p = get_device_for_file("HS110(EU)_1.0_real.json") - mocker.patch("kasa.smartplug.SmartPlug", return_value=p) - mocker.patch("kasa.smartplug.SmartPlug.update") - res = xdoctest.doctest_module("kasa.smartplug", "all") - assert not res["failed"] - - -def test_strip_examples(mocker): - """Test strip examples.""" - p = get_device_for_file("KP303(UK)_1.0.json") - mocker.patch("kasa.smartstrip.SmartStrip", return_value=p) - mocker.patch("kasa.smartstrip.SmartStrip.update") - res = xdoctest.doctest_module("kasa.smartstrip", "all") - assert not res["failed"] - - -def test_dimmer_examples(mocker): - """Test dimmer examples.""" - p = get_device_for_file("HS220(US)_1.0_real.json") - mocker.patch("kasa.smartdimmer.SmartDimmer", return_value=p) - mocker.patch("kasa.smartdimmer.SmartDimmer.update") - res = xdoctest.doctest_module("kasa.smartdimmer", "all") - assert not res["failed"] - - -def test_lightstrip_examples(mocker): - """Test lightstrip examples.""" - p = get_device_for_file("KL430(US)_1.0.json") - mocker.patch("kasa.smartlightstrip.SmartLightStrip", return_value=p) - mocker.patch("kasa.smartlightstrip.SmartLightStrip.update") - res = xdoctest.doctest_module("kasa.smartlightstrip", "all") - assert not res["failed"] - - -@pytest.mark.skipif( - sys.version_info < (3, 8), reason="3.7 handles asyncio.run differently" -) -def test_discovery_examples(mocker): - """Test discovery examples.""" - p = get_device_for_file("KP303(UK)_1.0.json") - - # This succeeds on python 3.8 but fails on 3.7 - # ValueError: a coroutine was expected, got [") - assert pattern.match(str(dev)) - - -async def test_childrens(dev): - """Make sure that children property is exposed by every device.""" - if dev.is_strip: - assert len(dev.children) > 0 - else: - assert len(dev.children) == 0 diff --git a/kasa/transports/__init__.py b/kasa/transports/__init__.py new file mode 100644 index 000000000..192b4156a --- /dev/null +++ b/kasa/transports/__init__.py @@ -0,0 +1,22 @@ +"""Package containing all supported transports.""" + +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 new file mode 100644 index 000000000..45b963fe8 --- /dev/null +++ b/kasa/transports/aestransport.py @@ -0,0 +1,500 @@ +"""Implementation of the TP-Link AES transport. + +Based on the work of https://github.com/petretiandrea/plugp100 +under compatible GNU GPL3 license. +""" + +from __future__ import annotations + +import base64 +import hashlib +import logging +import time +from collections.abc import AsyncGenerator +from enum import Enum, auto +from typing import TYPE_CHECKING, Any, cast + +from cryptography.hazmat.primitives import hashes, padding, serialization +from cryptography.hazmat.primitives.asymmetric import padding as asymmetric_padding +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +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, + TimeoutError, + _ConnectionError, + _RetryableError, +) +from kasa.httpclient import HttpClient +from kasa.json import dumps as json_dumps +from kasa.json import loads as json_loads + +from .basetransport import BaseTransport + +_LOGGER = logging.getLogger(__name__) + + +ONE_DAY_SECONDS = 86400 +SESSION_EXPIRE_BUFFER_SECONDS = 60 * 20 + + +def _sha1(payload: bytes) -> str: + sha1_algo = hashlib.sha1() # noqa: S324 + sha1_algo.update(payload) + return sha1_algo.hexdigest() + + +class TransportState(Enum): + """Enum for AES state.""" + + HANDSHAKE_REQUIRED = auto() # Handshake needed + LOGIN_REQUIRED = auto() # Login needed + ESTABLISHED = auto() # Ready to send requests + + +class AesTransport(BaseTransport): + """Implementation of the AES encryption protocol. + + AES is the name used in device discovery for TP-Link's TAPO encryption + protocol, sometimes used by newer firmware versions on kasa devices. + """ + + DEFAULT_PORT: int = 80 + SESSION_COOKIE_NAME = "TP_SESSIONID" + TIMEOUT_COOKIE_NAME = "TIMEOUT" + COMMON_HEADERS = { + "Content-Type": "application/json", + "requestByApp": "true", + "Accept": "application/json", + } + CONTENT_LENGTH = "Content-Length" + KEY_PAIR_CONTENT_LENGTH = 314 + + def __init__( + self, + *, + config: DeviceConfig, + ) -> None: + super().__init__(config=config) + + self._login_version = config.connection_type.login_version + 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.HANDSHAKE_REQUIRED + + self._encryption_session: AesEncyptionSession | None = None + self._session_expire_at: float | None = None + + self._session_cookie: dict[str, str] | None = None + + self._key_pair: KeyPair | None = None + if config.aes_keys: + aes_keys = config.aes_keys + self._key_pair = KeyPair.create_from_der_keys( + aes_keys["private"], aes_keys["public"] + ) + self._app_url = URL(f"http://{self._host}:{self._port}/app") + self._token_url: URL | None = None + + _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 + + @property + def credentials_hash(self) -> str | None: + """The hashed credentials used by the transport.""" + if self._credentials == Credentials(): + return None + 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(self._login_version == 2, credentials) + password_field_name = "password2" if self._login_version == 2 else "password" + return {password_field_name: pw, "username": un} + + @staticmethod + def hash_credentials(login_v2: bool, credentials: Credentials) -> tuple[str, str]: + """Hash the credentials.""" + un = base64.b64encode(_sha1(credentials.username.encode()).encode()).decode() + if login_v2: + pw = base64.b64encode( + _sha1(credentials.password.encode()).encode() + ).decode() + else: + pw = base64.b64encode(credentials.password.encode()).decode() + return un, pw + + def _handle_response_error_code(self, resp_dict: dict, msg: str) -> None: + error_code_raw = resp_dict.get("error_code") + 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 + if error_code is 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: + self._state = TransportState.HANDSHAKE_REQUIRED + raise AuthenticationError(msg, error_code=error_code) + raise DeviceError(msg, error_code=error_code) + + async def send_secure_passthrough(self, request: str) -> dict[str, Any]: + """Send encrypted message as passthrough.""" + if self._state is TransportState.ESTABLISHED and self._token_url: + url = self._token_url + else: + url = self._app_url + + encrypted_payload = self._encryption_session.encrypt(request.encode()) # type: ignore + passthrough_request = { + "method": "securePassthrough", + "params": {"request": encrypted_payload.decode()}, + } + status_code, resp_dict = await self._http_client.post( + url, + json=passthrough_request, + headers=self.COMMON_HEADERS, + cookies_dict=self._session_cookie, + ) + # _LOGGER.debug(f"secure_passthrough response is {status_code}: {resp_dict}") + + if status_code != 200: + raise KasaException( + f"{self._host} responded with an unexpected " + + f"status code {status_code} to passthrough" + ) + + if TYPE_CHECKING: + resp_dict = cast(dict[str, Any], resp_dict) + assert self._encryption_session is not None + + self._handle_response_error_code( + resp_dict, "Error sending secure_passthrough message" + ) + + raw_response: str = resp_dict["result"]["response"] + + try: + response = self._encryption_session.decrypt(raw_response.encode()) + ret_val = json_loads(response) + except Exception as ex: + try: + ret_val = json_loads(raw_response) + _LOGGER.debug( + "Received unencrypted response over secure passthrough from %s", + self._host, + ) + except Exception: + raise KasaException( + f"Unable to decrypt response from {self._host}, " + + f"error: {ex}, response: {raw_response}", + ex, + ) from ex + return ret_val # type: ignore[return-value] + + async def perform_login(self) -> None: + """Login to the device.""" + try: + await self.try_login(self._login_params) + _LOGGER.debug( + "%s: logged in with provided credentials", + self._host, + ) + except AuthenticationError as aex: + try: + if aex.error_code is not SmartErrorCode.LOGIN_ERROR: + raise aex + _LOGGER.debug( + "%s: trying login with default TAPO credentials", + self._host, + ) + if self._default_credentials is None: + self._default_credentials = get_default_credentials( + DEFAULT_CREDENTIALS["TAPO"] + ) + await self.perform_handshake() + await self.try_login(self._get_login_params(self._default_credentials)) + _LOGGER.debug( + "%s: logged in with default TAPO credentials", + self._host, + ) + except (AuthenticationError, _ConnectionError, TimeoutError): + 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_device", + "params": login_params, + "request_time_milis": round(time.time() * 1000), + } + request = json_dumps(login_request) + + resp_dict = await self.send_secure_passthrough(request) + self._handle_response_error_code(resp_dict, "Error logging in") + login_token = resp_dict["result"]["token"] + self._token_url = self._app_url.with_query(f"token={login_token}") + self._state = TransportState.ESTABLISHED + + async def _generate_key_pair_payload(self) -> AsyncGenerator: + """Generate the request body and return an ascyn_generator. + + This prevents the key pair being generated unless a connection + can be made to the device. + """ + _LOGGER.debug("Generating keypair") + if not self._key_pair: + kp = KeyPair.create_key_pair() + self._config.aes_keys = { + "private": kp.private_key_der_b64, + "public": kp.public_key_der_b64, + } + self._key_pair = kp + + pub_key = ( + "-----BEGIN PUBLIC KEY-----\n" + + self._key_pair.public_key_der_b64 # type: ignore[union-attr] + + "\n-----END PUBLIC KEY-----\n" + ) + handshake_params = {"key": pub_key} + request_body = {"method": "handshake", "params": handshake_params} + _LOGGER.debug("Handshake request: %s", request_body) + yield json_dumps(request_body).encode() + + async def perform_handshake(self) -> None: + """Perform the handshake.""" + _LOGGER.debug("Will perform handshaking...") + + self._token_url = None + self._session_expire_at = None + self._session_cookie = None + + # Device needs the content length or it will response with 500 + headers = { + **self.COMMON_HEADERS, + self.CONTENT_LENGTH: str(self.KEY_PAIR_CONTENT_LENGTH), + } + http_client = self._http_client + + status_code, resp_dict = await http_client.post( + self._app_url, + json=self._generate_key_pair_payload(), + headers=headers, + cookies_dict=self._session_cookie, + ) + + _LOGGER.debug("Device responded with: %s", resp_dict) + + if status_code != 200: + raise KasaException( + f"{self._host} responded with an unexpected " + + f"status code {status_code} to handshake" + ) + + if TYPE_CHECKING: + resp_dict = cast(dict[str, Any], resp_dict) + + self._handle_response_error_code(resp_dict, "Unable to complete handshake") + + handshake_key = resp_dict["result"]["key"] + + if ( + cookie := http_client.get_cookie(self.SESSION_COOKIE_NAME) # type: ignore + ) or ( + cookie := http_client.get_cookie("SESSIONID") # type: ignore + ): + self._session_cookie = {self.SESSION_COOKIE_NAME: cookie} + + timeout = int( + http_client.get_cookie(self.TIMEOUT_COOKIE_NAME) or ONE_DAY_SECONDS + ) + # There is a 24 hour timeout on the session cookie + # but the clock on the device is not always accurate + # so we set the expiry to 24 hours from now minus a buffer + self._session_expire_at = time.time() + timeout - SESSION_EXPIRE_BUFFER_SECONDS + if TYPE_CHECKING: + assert self._key_pair is not None + self._encryption_session = AesEncyptionSession.create_from_keypair( + handshake_key, self._key_pair + ) + + self._state = TransportState.LOGIN_REQUIRED + + _LOGGER.debug("Handshake with %s complete", self._host) + + def _handshake_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.""" + if ( + self._state is TransportState.HANDSHAKE_REQUIRED + or self._handshake_session_expired() + ): + await self.perform_handshake() + if self._state is not TransportState.ESTABLISHED: + try: + await self.perform_login() + # After a login failure handshake needs to + # be redone or a 9999 error is received. + except AuthenticationError as ex: + self._state = TransportState.HANDSHAKE_REQUIRED + raise ex + + return await self.send_secure_passthrough(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 handshake and login state.""" + self._state = TransportState.HANDSHAKE_REQUIRED + + +class AesEncyptionSession: + """Class for an AES encryption session.""" + + @staticmethod + def create_from_keypair( + handshake_key: str, keypair: KeyPair + ) -> AesEncyptionSession: + """Create the encryption session.""" + handshake_key_bytes: bytes = base64.b64decode(handshake_key.encode()) + + key_and_iv = keypair.decrypt_handshake_key(handshake_key_bytes) + if key_and_iv is None: + raise ValueError("Decryption failed!") + + return AesEncyptionSession(key_and_iv[:16], key_and_iv[16:]) + + def __init__(self, key: bytes, iv: bytes) -> None: + self.cipher = Cipher(algorithms.AES(key), modes.CBC(iv)) + self.padding_strategy = padding.PKCS7(algorithms.AES.block_size) + + def encrypt(self, data: bytes) -> bytes: + """Encrypt the message.""" + encryptor = self.cipher.encryptor() + padder = self.padding_strategy.padder() + padded_data = padder.update(data) + padder.finalize() + encrypted = encryptor.update(padded_data) + encryptor.finalize() + return base64.b64encode(encrypted) + + def decrypt(self, data: str | bytes) -> str: + """Decrypt the message.""" + decryptor = self.cipher.decryptor() + unpadder = self.padding_strategy.unpadder() + decrypted = decryptor.update(base64.b64decode(data)) + decryptor.finalize() + unpadded_data = unpadder.update(decrypted) + unpadder.finalize() + return unpadded_data.decode() + + +class KeyPair: + """Class for generating key pairs.""" + + @staticmethod + def create_key_pair(key_size: int = 1024) -> KeyPair: + """Create a key pair.""" + private_key = rsa.generate_private_key(public_exponent=65537, key_size=key_size) + public_key = private_key.public_key() + return KeyPair(private_key, public_key) + + @staticmethod + def create_from_der_keys( + private_key_der_b64: str, public_key_der_b64: str + ) -> KeyPair: + """Create a key pair.""" + key_bytes = base64.b64decode(private_key_der_b64.encode()) + private_key = cast( + rsa.RSAPrivateKey, serialization.load_der_private_key(key_bytes, None) + ) + key_bytes = base64.b64decode(public_key_der_b64.encode()) + public_key = cast( + rsa.RSAPublicKey, serialization.load_der_public_key(key_bytes, None) + ) + + return KeyPair(private_key, public_key) + + def __init__( + self, private_key: rsa.RSAPrivateKey, public_key: rsa.RSAPublicKey + ) -> None: + self.private_key = private_key + self.public_key = public_key + self.private_key_der_bytes = self.private_key.private_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + self.public_key_der_bytes = self.public_key.public_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + self.private_key_der_b64 = base64.b64encode(self.private_key_der_bytes).decode() + self.public_key_der_b64 = base64.b64encode(self.public_key_der_bytes).decode() + + def get_public_pem(self) -> bytes: + """Get public key in PEM encoding.""" + return self.public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + + def decrypt_handshake_key(self, encrypted_key: bytes) -> bytes: + """Decrypt an aes handshake key.""" + decrypted = self.private_key.decrypt( + encrypted_key, asymmetric_padding.PKCS1v15() + ) + return decrypted + + def decrypt_discovery_key(self, encrypted_key: bytes) -> bytes: + """Decrypt an aes discovery key.""" + decrypted = self.private_key.decrypt( + encrypted_key, + asymmetric_padding.OAEP( + mgf=asymmetric_padding.MGF1(algorithm=hashes.SHA1()), # noqa: S303 + algorithm=hashes.SHA1(), # noqa: S303 + label=None, + ), + ) + return decrypted diff --git a/kasa/transports/basetransport.py b/kasa/transports/basetransport.py new file mode 100644 index 000000000..1f1ed7d95 --- /dev/null +++ b/kasa/transports/basetransport.py @@ -0,0 +1,55 @@ +"""Base class for all transport implementations. + +All transport classes must derive from this to implement the common interface. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from kasa import DeviceConfig + + +class BaseTransport(ABC): + """Base class for all TP-Link protocol transports.""" + + DEFAULT_TIMEOUT = 5 + + def __init__( + self, + *, + config: DeviceConfig, + ) -> None: + """Create a protocol object.""" + self._config = config + self._host = config.host + self._port = config.port_override or self.default_port + self._credentials = config.credentials + self._credentials_hash = config.credentials_hash + if not config.timeout: + config.timeout = self.DEFAULT_TIMEOUT + self._timeout = config.timeout + + @property + @abstractmethod + def default_port(self) -> int: + """The default port for the transport.""" + + @property + @abstractmethod + def credentials_hash(self) -> str | None: + """The hashed credentials used by the transport.""" + + @abstractmethod + async def send(self, request: str) -> dict: + """Send a message to the device and return a response.""" + + @abstractmethod + async def close(self) -> None: + """Close the transport. Abstract method to be overriden.""" + + @abstractmethod + async def reset(self) -> None: + """Reset internal state.""" diff --git a/kasa/transports/klaptransport.py b/kasa/transports/klaptransport.py new file mode 100644 index 000000000..8253e0aef --- /dev/null +++ b/kasa/transports/klaptransport.py @@ -0,0 +1,555 @@ +"""Implementation of the TP-Link Klap Home Protocol. + +Encryption/Decryption methods based on the works of +Simon Wilkinson and Chris Weeldon + +Klap devices that have never been connected to the kasa +cloud should work with blank credentials. +Devices that have been connected to the kasa cloud will +switch intermittently between the users cloud credentials +and default kasa credentials that are hardcoded. +This appears to be an issue with the devices. + +The protocol works by doing a two stage handshake to obtain +and encryption key and session id cookie. + +Authentication uses an auth_hash which is +md5(md5(username),md5(password)) + +handshake1: client sends a random 16 byte local_seed to the +device and receives a random 16 bytes remote_seed, followed +by sha256(local_seed + auth_hash). It also returns a +TP_SESSIONID in the cookie header. This implementation +then checks this value against the possible auth_hashes +described above (user cloud, kasa hardcoded, blank). If it +finds a match it moves onto handshake2 + +handshake2: client sends sha25(remote_seed + auth_hash) to +the device along with the TP_SESSIONID. Device responds with +200 if successful. It generally will be because this +implementation checks the auth_hash it received during handshake1 + +encryption: local_seed, remote_seed and auth_hash are now used +for encryption. The last 4 bytes of the initialization vector +are used as a sequence number that increments every time the +client calls encrypt and this sequence number is sent as a +url parameter to the device along with the encrypted payload + +https://gist.github.com/chriswheeldon/3b17d974db3817613c69191c0480fe55 +https://github.com/python-kasa/python-kasa/pull/117 + +""" + +from __future__ import annotations + +import asyncio +import base64 +import datetime +import hashlib +import logging +import secrets +import ssl +import struct +import time +from asyncio import Future +from collections.abc import Generator +from typing import TYPE_CHECKING, Any, cast + +from cryptography.hazmat.primitives import padding +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +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, _RetryableError +from kasa.httpclient import HttpClient +from kasa.json import loads as json_loads +from kasa.protocols.protocol import md5 + +from .basetransport import BaseTransport + +_LOGGER = logging.getLogger(__name__) + + +ONE_DAY_SECONDS = 86400 +SESSION_EXPIRE_BUFFER_SECONDS = 60 * 20 + +PACK_SIGNED_LONG = struct.Struct(">l").pack + + +def _sha256(payload: bytes) -> bytes: + return hashlib.sha256(payload).digest() # noqa: S324 + + +def _sha1(payload: bytes) -> bytes: + return hashlib.sha1(payload).digest() # noqa: S324 + + +class KlapTransport(BaseTransport): + """Implementation of the KLAP encryption protocol. + + KLAP is the name used in device discovery for TP-Link's new encryption + protocol, used by newer firmware versions. + """ + + 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, + *, + config: DeviceConfig, + ) -> None: + super().__init__(config=config) + + self._http_client = HttpClient(config) + self._local_seed: bytes | None = None + if ( + not self._credentials or self._credentials.username is None + ) and not self._credentials_hash: + self._credentials = Credentials() + if self._credentials: + self._local_auth_hash = self.generate_auth_hash(self._credentials) + self._local_auth_owner = self.generate_owner_hash(self._credentials).hex() + else: + self._local_auth_hash = base64.b64decode(self._credentials_hash.encode()) # type: ignore[union-attr] + self._default_credentials_auth_hash: dict[str, bytes] = {} + self._blank_auth_hash: bytes | None = None + self._handshake_lock = asyncio.Lock() + self._query_lock = asyncio.Lock() + self._handshake_done: bool = False + + self._encryption_session: KlapEncryptionSession | None = None + self._session_expire_at: float | None = None + + self._session_cookie: dict[str, Any] | None = None + + _LOGGER.debug("Created KLAP transport for %s", self._host) + 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 + def credentials_hash(self) -> str | None: + """The hashed credentials used by the transport.""" + if self._credentials == Credentials(): + return None + return base64.b64encode(self._local_auth_hash).decode() + + async def perform_handshake1(self) -> tuple[bytes, bytes, bytes]: + """Perform handshake1.""" + local_seed: bytes = secrets.token_bytes(16) + + # Handshake 1 has a payload of local_seed + # and a response of 16 bytes, followed by + # sha256(remote_seed | auth_hash) + + payload = local_seed + + url = self._app_url / "handshake1" + + 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( + "Handshake1 posted at %s. Host is %s, " + "Response status is %s, Request was %s", + datetime.datetime.now(), + self._host, + response_status, + payload.hex(), + ) + + if response_status != 200: + raise KasaException( + f"Device {self._host} responded with {response_status} to handshake1" + ) + + response_data = cast(bytes, response_data) + remote_seed: bytes = response_data[0:16] + server_hash = response_data[16:] + + if len(server_hash) != 32: + raise KasaException( + f"Device {self._host} responded with unexpected klap response " + + f"{response_data!r} to handshake1" + ) + + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "Handshake1 success at %s. Host is %s, " + "Server remote_seed is: %s, server hash is: %s", + datetime.datetime.now(), + self._host, + remote_seed.hex(), + server_hash.hex(), + ) + + local_seed_auth_hash = self.handshake1_seed_auth_hash( + local_seed, remote_seed, self._local_auth_hash + ) # type: ignore + + # Check the response from the device with local credentials + if local_seed_auth_hash == server_hash: + _LOGGER.debug("handshake1 hashes match with expected credentials") + return local_seed, remote_seed, self._local_auth_hash # type: ignore + + # Now check against the default setup credentials + for key, value in DEFAULT_CREDENTIALS.items(): + if key not in self._default_credentials_auth_hash: + default_credentials = get_default_credentials(value) + self._default_credentials_auth_hash[key] = self.generate_auth_hash( + default_credentials + ) + + default_credentials_seed_auth_hash = self.handshake1_seed_auth_hash( + local_seed, + remote_seed, + self._default_credentials_auth_hash[key], # type: ignore + ) + + if default_credentials_seed_auth_hash == server_hash: + _LOGGER.debug( + "Device response did not match our expected hash on ip %s," + "but an authentication with %s default credentials worked", + self._host, + key, + ) + return local_seed, remote_seed, self._default_credentials_auth_hash[key] # type: ignore + + # Finally check against blank credentials if not already blank + blank_creds = Credentials() + if self._credentials != blank_creds: + if not self._blank_auth_hash: + self._blank_auth_hash = self.generate_auth_hash(blank_creds) + + blank_seed_auth_hash = self.handshake1_seed_auth_hash( + local_seed, + remote_seed, + self._blank_auth_hash, # type: ignore + ) + + if blank_seed_auth_hash == server_hash: + _LOGGER.debug( + "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"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 perform_handshake2( + self, local_seed: bytes, remote_seed: bytes, auth_hash: bytes + ) -> KlapEncryptionSession: + """Perform handshake2.""" + # Handshake 2 has the following payload: + # sha256(serverBytes | authenticator) + + url = self._app_url / "handshake2" + + payload = self.handshake2_seed_auth_hash(local_seed, remote_seed, auth_hash) + + response_status, _ = await self._http_client.post( + url, + data=payload, + cookies_dict=self._session_cookie, + ssl=await self._get_ssl_context(), + ) + + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "Handshake2 posted %s. Host is %s, " + "Response status is %s, Request was %s", + datetime.datetime.now(), + self._host, + response_status, + payload.hex(), + ) + + if response_status != 200: + # This shouldn't be caused by incorrect + # credentials so don't raise AuthenticationError + raise KasaException( + f"Device {self._host} responded with {response_status} to handshake2" + ) + + return KlapEncryptionSession(local_seed, remote_seed, auth_hash) + + async def perform_handshake(self) -> None: + """Perform handshake1 and handshake2. + + Sets the encryption_session if successful. + """ + _LOGGER.debug("Starting handshake with %s", self._host) + self._handshake_done = False + self._session_expire_at = None + self._session_cookie = None + + local_seed, remote_seed, auth_hash = await self.perform_handshake1() + http_client = self._http_client + if cookie := http_client.get_cookie(self.SESSION_COOKIE_NAME): # type: ignore + self._session_cookie = {self.SESSION_COOKIE_NAME: cookie} + # The device returns a TIMEOUT cookie on handshake1 which + # it doesn't like to get back so we store the one we want + timeout = int( + http_client.get_cookie(self.TIMEOUT_COOKIE_NAME) or ONE_DAY_SECONDS + ) + # There is a 24 hour timeout on the session cookie + # but the clock on the device is not always accurate + # so we set the expiry to 24 hours from now minus a buffer + self._session_expire_at = ( + time.monotonic() + timeout - SESSION_EXPIRE_BUFFER_SECONDS + ) + self._encryption_session = await self.perform_handshake2( + local_seed, remote_seed, auth_hash + ) + self._handshake_done = True + + _LOGGER.debug("Handshake with %s complete", self._host) + + def _handshake_session_expired(self) -> bool: + """Return true if session has expired.""" + return ( + self._session_expire_at is None + or self._session_expire_at - time.monotonic() <= 0 + ) + + async def send(self, request: str) -> Generator[Future, None, dict[str, str]]: # type: ignore[override] + """Send the request.""" + if not self._handshake_done or self._handshake_session_expired(): + await self.perform_handshake() + + # Check for mypy + if self._encryption_session is not None: + payload, seq = self._encryption_session.encrypt(request.encode()) + + response_status, response_data = await self._http_client.post( + self._request_url, + params={"seq": seq}, + data=payload, + cookies_dict=self._session_cookie, + ssl=await self._get_ssl_context(), + ) + + msg = ( + f"Host is {self._host}, " + + f"Sequence is {seq}, " + + f"Response status is {response_status}, Request was {request}" + ) + if response_status != 200: + _LOGGER.error("Query failed after successful authentication: %s", msg) + # If we failed with a security error, force a new handshake next time. + if response_status == 403: + self._handshake_done = False + raise _RetryableError( + "Got a security error from %s after handshake completed", self._host + ) + else: + raise KasaException( + f"Device {self._host} responded with {response_status} to " + f"request with seq {seq}" + ) + else: + _LOGGER.debug("Device %s query posted %s", self._host, msg) + + if TYPE_CHECKING: + assert self._encryption_session + assert isinstance(response_data, bytes) + try: + decrypted_response = self._encryption_session.decrypt(response_data) + except Exception as ex: + raise KasaException( + f"Error trying to decrypt device {self._host} response: {ex}" + ) from ex + + json_payload = json_loads(decrypted_response) + + _LOGGER.debug("Device %s query response received", self._host) + + return json_payload + + 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 handshake state.""" + self._handshake_done = False + + @staticmethod + def generate_auth_hash(creds: Credentials) -> bytes: + """Generate an md5 auth hash for the protocol on the supplied credentials.""" + un = creds.username + pw = creds.password + + return md5(md5(un.encode()) + md5(pw.encode())) + + @staticmethod + def handshake1_seed_auth_hash( + local_seed: bytes, remote_seed: bytes, auth_hash: bytes + ) -> bytes: + """Generate an md5 auth hash for the protocol on the supplied credentials.""" + return _sha256(local_seed + auth_hash) + + @staticmethod + def handshake2_seed_auth_hash( + local_seed: bytes, remote_seed: bytes, auth_hash: bytes + ) -> bytes: + """Generate an md5 auth hash for the protocol on the supplied credentials.""" + return _sha256(remote_seed + auth_hash) + + @staticmethod + def generate_owner_hash(creds: Credentials) -> bytes: + """Return the MD5 hash of the username in this object.""" + 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.""" + + @staticmethod + def generate_auth_hash(creds: Credentials) -> bytes: + """Generate an md5 auth hash for the protocol on the supplied credentials.""" + un = creds.username + pw = creds.password + + return _sha256(_sha1(un.encode()) + _sha1(pw.encode())) + + @staticmethod + def handshake1_seed_auth_hash( + local_seed: bytes, remote_seed: bytes, auth_hash: bytes + ) -> bytes: + """Generate an md5 auth hash for the protocol on the supplied credentials.""" + return _sha256(local_seed + remote_seed + auth_hash) + + @staticmethod + def handshake2_seed_auth_hash( + local_seed: bytes, remote_seed: bytes, auth_hash: bytes + ) -> bytes: + """Generate an md5 auth hash for the protocol on the supplied credentials.""" + return _sha256(remote_seed + local_seed + auth_hash) + + +class KlapEncryptionSession: + """Class to represent an encryption session and it's internal state. + + i.e. sequence number which the device expects to increment. + """ + + _cipher: Cipher + + def __init__(self, local_seed: bytes, remote_seed: bytes, user_hash: bytes) -> None: + self.local_seed = local_seed + self.remote_seed = remote_seed + self.user_hash = user_hash + self._key = self._key_derive(local_seed, remote_seed, user_hash) + (self._iv, self._seq) = self._iv_derive(local_seed, remote_seed, user_hash) + self._aes = algorithms.AES(self._key) + self._sig = self._sig_derive(local_seed, remote_seed, user_hash) + + def _key_derive( + self, local_seed: bytes, remote_seed: bytes, user_hash: bytes + ) -> bytes: + payload = b"lsk" + local_seed + remote_seed + user_hash + return hashlib.sha256(payload).digest()[:16] + + def _iv_derive( + self, local_seed: bytes, remote_seed: bytes, user_hash: bytes + ) -> tuple[bytes, int]: + # iv is first 16 bytes of sha256, where the last 4 bytes forms the + # sequence number used in requests and is incremented on each request + payload = b"iv" + local_seed + remote_seed + user_hash + fulliv = hashlib.sha256(payload).digest() + seq = int.from_bytes(fulliv[-4:], "big", signed=True) + return (fulliv[:12], seq) + + def _sig_derive( + self, local_seed: bytes, remote_seed: bytes, user_hash: bytes + ) -> bytes: + # used to create a hash with which to prefix each request + payload = b"ldk" + local_seed + remote_seed + user_hash + return hashlib.sha256(payload).digest()[:28] + + def _generate_cipher(self) -> None: + iv_seq = self._iv + PACK_SIGNED_LONG(self._seq) + cbc = modes.CBC(iv_seq) + self._cipher = Cipher(self._aes, cbc) + + def encrypt(self, msg: bytes | str) -> tuple[bytes, int]: + """Encrypt the data and increment the sequence number.""" + self._seq += 1 + self._generate_cipher() + + if isinstance(msg, str): + msg = msg.encode("utf-8") + + encryptor = self._cipher.encryptor() + padder = padding.PKCS7(128).padder() + padded_data = padder.update(msg) + padder.finalize() + ciphertext = encryptor.update(padded_data) + encryptor.finalize() + signature = hashlib.sha256( + self._sig + PACK_SIGNED_LONG(self._seq) + ciphertext + ).digest() + return (signature + ciphertext, self._seq) + + def decrypt(self, msg: bytes) -> str: + """Decrypt the data.""" + decryptor = self._cipher.decryptor() + dp = decryptor.update(msg[32:]) + decryptor.finalize() + unpadder = padding.PKCS7(128).unpadder() + plaintextbytes = unpadder.update(dp) + unpadder.finalize() + + return plaintextbytes.decode() 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 new file mode 100644 index 000000000..525085b05 --- /dev/null +++ b/kasa/transports/sslaestransport.py @@ -0,0 +1,673 @@ +"""Implementation of the TP-Link SSL AES transport.""" + +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import logging +import secrets +import ssl +from contextlib import suppress +from enum import Enum, auto +from typing import TYPE_CHECKING, Any, cast + +from yarl import URL + +from ..credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials +from ..deviceconfig import DeviceConfig +from ..exceptions import ( + SMART_AUTHENTICATION_ERRORS, + SMART_RETRYABLE_ERRORS, + AuthenticationError, + DeviceError, + KasaException, + SmartErrorCode, + _RetryableError, +) +from ..httpclient import HttpClient +from ..json import dumps as json_dumps +from ..json import loads as json_loads +from . import AesEncyptionSession, BaseTransport + +_LOGGER = logging.getLogger(__name__) + + +ONE_DAY_SECONDS = 86400 +SESSION_EXPIRE_BUFFER_SECONDS = 60 * 20 + + +def _sha256(payload: bytes) -> bytes: + return hashlib.sha256(payload).digest() # noqa: S324 + + +def _md5_hash(payload: bytes) -> str: + return hashlib.md5(payload).hexdigest().upper() # noqa: S324 + + +def _sha256_hash(payload: bytes) -> str: + return hashlib.sha256(payload).hexdigest().upper() # noqa: S324 + + +class TransportState(Enum): + """Enum for AES state.""" + + HANDSHAKE_REQUIRED = auto() # Handshake needed + ESTABLISHED = auto() # Ready to send requests + + +class SslAesTransport(BaseTransport): + """Implementation of the AES encryption protocol. + + AES is the name used in device discovery for TP-Link's TAPO encryption + protocol, sometimes used by newer firmware versions on kasa devices. + """ + + DEFAULT_PORT: int = 443 + COMMON_HEADERS = { + "Content-Type": "application/json; charset=UTF-8", + "requestByApp": "true", + "Accept": "application/json", + "Accept-Encoding": "gzip, deflate", + "User-Agent": "Tapo CameraClient Android", + } + CIPHERS = ":".join( + [ + "ECDHE-RSA-AES128-GCM-SHA256", + "AES256-GCM-SHA384", + "AES256-SHA256", + "AES128-GCM-SHA256", + "AES128-SHA256", + "AES256-SHA", + ] + ) + DEFAULT_TIMEOUT = 10 + + def __init__( + self, + *, + config: DeviceConfig, + ) -> None: + super().__init__(config=config) + + self._login_version = config.connection_type.login_version + if ( + 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 + ) + self._http_client: HttpClient = HttpClient(config) + + self._state = TransportState.HANDSHAKE_REQUIRED + + self._encryption_session: AesEncyptionSession | None = None + self._session_expire_at: float | None = None + + self._host_port = f"{self._host}:{self._port}" + self._app_url = URL(f"https://{self._host_port}") + self._token_url: URL | None = None + self._ssl_context: ssl.SSLContext | None = None + ref = str(self._token_url) if self._token_url else str(self._app_url) + self._headers = { + **self.COMMON_HEADERS, + "Host": self._host_port, + "Referer": ref, + } + self._seq: int | None = None + self._pwd_hash: str | None = None + self._username: str | None = None + self._password: str | None = None + if self._credentials != Credentials() and self._credentials: + self._username = self._credentials.username + self._password = self._credentials.password + elif self._credentials_hash: + ch = json_loads(base64.b64decode(self._credentials_hash.encode())) + 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 + def _create_b64_credentials(credentials: Credentials) -> str: + ch = {"un": credentials.username, "pwd": credentials.password} + return base64.b64encode(json_dumps(ch).encode()).decode() + + @property + def credentials_hash(self) -> str | None: + """The hashed credentials used by the transport.""" + if self._credentials == Credentials(): + return None + if not self._credentials and self._credentials_hash: + return self._credentials_hash + if (cred := self._credentials) and cred.password and cred.username: + return self._create_b64_credentials(cred) + return None + + def _get_response_error(self, resp_dict: Any) -> SmartErrorCode: + error_code_raw = resp_dict.get("error_code") + 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 _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: + 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: + self._state = TransportState.HANDSHAKE_REQUIRED + raise AuthenticationError(msg, error_code=error_code) + raise DeviceError(msg, error_code=error_code) + + 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 + + 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 + + async def send_secure_passthrough(self, request: str) -> dict[str, Any]: + """Send encrypted message as passthrough.""" + if self._state is TransportState.ESTABLISHED and self._token_url: + url = self._token_url + 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", + "params": {"request": encrypted_payload.decode()}, + } + passthrough_request_str = json_dumps(passthrough_request) + if TYPE_CHECKING: + assert self._pwd_hash + assert self._local_nonce + assert self._seq + tag = self.generate_tag( + passthrough_request_str, self._local_nonce, self._pwd_hash, self._seq + ) + headers = {**self._headers, "Seq": str(self._seq), "Tapo_tag": tag} + self._seq += 1 + status_code, resp_dict = await self._http_client.post( + url, + json=passthrough_request_str, + headers=headers, + 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 " + + f"status code {status_code} to passthrough" + ) + + self._handle_response_error_code( + resp_dict, "Error sending secure_passthrough message" + ) + + if TYPE_CHECKING: + resp_dict = cast(dict[str, Any], resp_dict) + + if "result" in resp_dict and "response" in resp_dict["result"]: + raw_response: str = resp_dict["result"]["response"] + else: + # Tapo Cameras respond unencrypted to single requests. + return resp_dict + + try: + response = self._encryption_session.decrypt(raw_response.encode()) + ret_val = json_loads(response) + except Exception as ex: + try: + ret_val = json_loads(raw_response) + _LOGGER.debug( + "Received unencrypted response over secure passthrough from %s", + self._host, + ) + except Exception: + raise KasaException( + f"Unable to decrypt response from {self._host}, " + + f"error: {ex}, response: {raw_response}", + ex, + ) 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 + ) -> str: + """Generate an auth hash for the protocol on the supplied credentials.""" + expected_confirm_bytes = _sha256_hash( + local_nonce.encode() + pwd_hash.encode() + server_nonce.encode() + ) + return expected_confirm_bytes + server_nonce + local_nonce + + @staticmethod + def generate_digest_password( + local_nonce: str, server_nonce: str, pwd_hash: str + ) -> str: + """Generate an auth hash for the protocol on the supplied credentials.""" + digest_password_hash = _sha256_hash( + pwd_hash.encode() + local_nonce.encode() + server_nonce.encode() + ) + return ( + digest_password_hash.encode() + local_nonce.encode() + server_nonce.encode() + ).decode() + + @staticmethod + def generate_encryption_token( + token_type: str, local_nonce: str, server_nonce: str, pwd_hash: str + ) -> bytes: + """Generate encryption token.""" + hashedKey = _sha256_hash( + local_nonce.encode() + pwd_hash.encode() + server_nonce.encode() + ) + return _sha256( + token_type.encode() + + local_nonce.encode() + + server_nonce.encode() + + hashedKey.encode() + )[:16] + + @staticmethod + def generate_tag(request: str, local_nonce: str, pwd_hash: str, seq: int) -> str: + """Generate the tag header from the request for the header.""" + pwd_nonce_hash = _sha256_hash(pwd_hash.encode() + local_nonce.encode()) + tag = _sha256_hash( + pwd_nonce_hash.encode() + request.encode() + str(seq).encode() + ) + return tag + + async def perform_handshake(self) -> None: + """Perform the handshake.""" + 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 + ) -> None: + """Perform the handshake.""" + _LOGGER.debug("Performing handshake2 ...") + digest_password = self.generate_digest_password( + local_nonce, server_nonce, pwd_hash + ) + body = { + "method": "login", + "params": { + "cnonce": local_nonce, + "encrypt_type": "3", + "digest_passwd": digest_password, + "username": self._username, + }, + } + http_client = self._http_client + status_code, resp_dict = await 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 handshake2" + ) + resp_dict = cast(dict, resp_dict) + if ( + error_code := self._get_response_error(resp_dict) + ) and error_code is SmartErrorCode.INVALID_NONCE: + raise AuthenticationError( + f"Invalid password hash in handshake2 for {self._host}" + ) + + self._handle_response_error_code(resp_dict, "Error in handshake2") + + self._seq = resp_dict["result"]["start_seq"] + stok = resp_dict["result"]["stok"] + self._token_url = URL(f"{str(self._app_url)}/stok={stok}/ds") + self._pwd_hash = pwd_hash + self._local_nonce = local_nonce + lsk = self.generate_encryption_token("lsk", local_nonce, server_nonce, pwd_hash) + ivb = self.generate_encryption_token("ivb", local_nonce, server_nonce, pwd_hash) + self._encryption_session = AesEncyptionSession(lsk, ivb) + self._state = TransportState.ESTABLISHED + _LOGGER.debug("Handshake2 complete ...") + + 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 + or (error_code := self._get_response_error(resp_dict)) + 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 %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.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: + resp_dict = cast(dict[str, Any], resp_dict) + + server_nonce = resp_dict["result"]["data"]["nonce"] + device_confirm = resp_dict["result"]["data"]["device_confirm"] + + pwd_hash = _sha256_hash(self._pwd_to_hash().encode()) + + expected_confirm_sha256 = self.generate_confirm_hash( + local_nonce, server_nonce, pwd_hash + ) + if device_confirm == expected_confirm_sha256: + _LOGGER.debug("Credentials match") + return local_nonce, server_nonce, pwd_hash + + if TYPE_CHECKING: + assert self._credentials + assert self._credentials.password + + pwd_hash = _md5_hash(self._pwd_to_hash().encode()) + + expected_confirm_md5 = self.generate_confirm_hash( + local_nonce, server_nonce, pwd_hash + ) + if device_confirm == expected_confirm_md5: + _LOGGER.debug("Credentials match") + return local_nonce, server_nonce, pwd_hash + + 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("Sending handshake1...") + + body = { + "method": "login", + "params": { + "cnonce": local_nonce, + "encrypt_type": "3", + "username": username, + }, + } + http_client = self._http_client + + status_code, resp_dict = await http_client.post( + self._app_url, + json=body, + headers=self._headers, + ssl=await self._get_ssl_context(), + ) + + _LOGGER.debug("Device responded with status %s: %s", status_code, resp_dict) + + if status_code != 200: + raise KasaException( + f"{self._host} responded with an unexpected " + + f"status code {status_code} to handshake1" + ) + + return cast(dict, resp_dict) + + async def send(self, request: str) -> dict[str, Any]: + """Send the request.""" + if self._state is TransportState.HANDSHAKE_REQUIRED: + await self.perform_handshake() + + 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.""" + await self.reset() + await self._http_client.close() + + async def reset(self) -> None: + """Reset internal handshake state.""" + self._state = TransportState.HANDSHAKE_REQUIRED + self._encryption_session = None + self._seq = 0 + self._pwd_hash = None + self._local_nonce = None 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 new file mode 100644 index 000000000..da77c899d --- /dev/null +++ b/kasa/transports/xortransport.py @@ -0,0 +1,247 @@ +"""Implementation of the legacy TP-Link Smart Home Protocol. + +Encryption/Decryption methods based on the works of +Lubomir Stroetmann and Tobias Esser + +https://www.softscheck.com/en/reverse-engineering-tp-link-hs110/ +https://github.com/softScheck/tplink-smartplug/ + +which are licensed under the Apache License, Version 2.0 +http://www.apache.org/licenses/LICENSE-2.0 +""" + +from __future__ import annotations + +import asyncio +import contextlib +import errno +import logging +import socket +import struct +from asyncio import timeout as asyncio_timeout +from collections.abc import Generator + +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.ENETUNREACH, + errno.ECONNREFUSED, +} +_UNSIGNED_INT_NETWORK_ORDER = struct.Struct(">I") + + +class XorTransport(BaseTransport): + """XorTransport class.""" + + DEFAULT_PORT: int = 9999 + BLOCK_SIZE = 4 + + def __init__(self, *, config: DeviceConfig) -> None: + super().__init__(config=config) + self.reader: asyncio.StreamReader | None = None + self.writer: asyncio.StreamWriter | None = None + self.query_lock = asyncio.Lock() + self.loop: asyncio.AbstractEventLoop | None = None + + @property + def default_port(self) -> int: + """Default port for the transport.""" + return self.DEFAULT_PORT + + @property + def credentials_hash(self) -> str | None: + """The hashed credentials used by the transport.""" + return None + + async def _connect(self, timeout: int) -> None: + """Try to connect or reconnect to the device.""" + if self.writer: + return + self.reader = self.writer = None + + task = asyncio.open_connection(self._host, self._port) + async with asyncio_timeout(timeout): + self.reader, self.writer = await task + sock: socket.socket = self.writer.get_extra_info("socket") + # Ensure our packets get sent without delay as we do all + # our writes in a single go and we do not want any buffering + # which would needlessly delay the request or risk overloading + # the buffer on the device + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + + async def _execute_send(self, request: str) -> dict: + """Execute a query on the device and wait for the response.""" + assert self.writer is not None # noqa: S101 + assert self.reader is not None # noqa: S101 + _LOGGER.debug("Device %s sending query %s", self._host, request) + + self.writer.write(XorEncryption.encrypt(request)) + await self.writer.drain() + + packed_block_size = await self.reader.readexactly(self.BLOCK_SIZE) + length = _UNSIGNED_INT_NETWORK_ORDER.unpack(packed_block_size)[0] + + buffer = await self.reader.readexactly(length) + response = XorEncryption.decrypt(buffer) + json_payload = json_loads(response) + + _LOGGER.debug("Device %s query response received", self._host) + + return json_payload + + async def close(self) -> None: + """Close the connection.""" + writer = self.writer + self.close_without_wait() + if writer: + with contextlib.suppress(Exception): + await writer.wait_closed() + + def close_without_wait(self) -> None: + """Close the connection without waiting for the connection to close.""" + writer = self.writer + self.reader = self.writer = None + if writer: + writer.close() + + async def reset(self) -> None: + """Reset the transport. + + The transport cannot be reset so we must close instead. + """ + await self.close() + + async def send(self, request: str) -> dict: + """Send a message to the device and return a response.""" + # + # Most of the time we will already be connected if the device is online + # and the connect call will do nothing and return right away + # + # However, if we get an unrecoverable error (_NO_RETRY_ERRORS and + # ConnectionRefusedError) we do not want to keep trying since many + # connection open/close operations in the same time frame can block + # the event loop. + # 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( + f"Unable to connect to the device: {self._host}:{self._port}: {ex}" + ) from ex + except OSError as ex: + await self.reset() + if ex.errno in _NO_RETRY_ERRORS: + raise KasaException( + f"Unable to connect to the device: {self._host}:{self._port}: {ex}" + ) from ex + else: + raise _RetryableError( + 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: {self._host}:{self._port}: {ex}" + ) from ex + except BaseException: + # Likely something cancelled the task so we need to close the connection + # as we are not in an indeterminate state + self.close_without_wait() + raise + + try: + assert self.reader is not None # noqa: S101 + 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( + f"Unable to query the device {self._host}:{self._port}: {ex}" + ) from ex + except BaseException: + # Likely something cancelled the task so we need to close the connection + # as we are not in an indeterminate state + self.close_without_wait() + raise + + def __del__(self) -> None: + if self.writer and self.loop and self.loop.is_running(): + # Since __del__ will be called when python does + # garbage collection is can happen in the event loop thread + # or in another thread so we need to make sure the call to + # close is called safely with call_soon_threadsafe + self.loop.call_soon_threadsafe(self.writer.close) + + +class XorEncryption: + """XorEncryption class.""" + + INITIALIZATION_VECTOR = 171 + + @staticmethod + def _xor_payload(unencrypted: bytes) -> Generator[int, None, None]: + key = XorEncryption.INITIALIZATION_VECTOR + for unencryptedbyte in unencrypted: + key = key ^ unencryptedbyte + yield key + + @staticmethod + def encrypt(request: str) -> bytes: + """Encrypt a request for a TP-Link Smart Home Device. + + :param request: plaintext request data + :return: ciphertext to be send over wire, in bytes + """ + plainbytes = request.encode() + return _UNSIGNED_INT_NETWORK_ORDER.pack(len(plainbytes)) + bytes( + XorEncryption._xor_payload(plainbytes) + ) + + @staticmethod + def _xor_encrypted_payload(ciphertext: bytes) -> Generator[int, None, None]: + key = XorEncryption.INITIALIZATION_VECTOR + for cipherbyte in ciphertext: + plainbyte = key ^ cipherbyte + key = cipherbyte + yield plainbyte + + @staticmethod + def decrypt(ciphertext: bytes) -> str: + """Decrypt a response of a TP-Link Smart Home Device. + + :param ciphertext: encrypted response data + :return: plaintext response + """ + return bytes(XorEncryption._xor_encrypted_payload(ciphertext)).decode() + + +# Try to load the kasa_crypt module and if it is available +try: + from kasa_crypt import decrypt, encrypt + + XorEncryption.decrypt = decrypt # type: ignore[assignment] + XorEncryption.encrypt = encrypt # type: ignore[assignment] +except ImportError: + pass diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index f27a54d06..000000000 --- a/poetry.lock +++ /dev/null @@ -1,1071 +0,0 @@ -[[package]] -category = "main" -description = "A configurable sidebar-enabled Sphinx theme" -name = "alabaster" -optional = true -python-versions = "*" -version = "0.7.12" - -[[package]] -category = "main" -description = "High level compatibility layer for multiple asynchronous event loop implementations" -name = "anyio" -optional = false -python-versions = ">=3.5.3" -version = "1.4.0" - -[package.dependencies] -async-generator = "*" -idna = ">=2.8" -sniffio = ">=1.1" - -[package.extras] -curio = ["curio (>=0.9)"] -doc = ["sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"] -test = ["coverage (>=4.5)", "hypothesis (>=4.0)", "pytest (>=3.7.2)", "uvloop"] -trio = ["trio (>=0.12)"] - -[[package]] -category = "dev" -description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -name = "appdirs" -optional = false -python-versions = "*" -version = "1.4.4" - -[[package]] -category = "main" -description = "Async generators and context managers for Python 3.5+" -name = "async-generator" -optional = false -python-versions = ">=3.5" -version = "1.10" - -[[package]] -category = "main" -description = "A simple anyio-compatible fork of Click, for powerful command line utilities." -name = "asyncclick" -optional = false -python-versions = ">=3.6" -version = "7.0.9" - -[package.dependencies] -anyio = "*" - -[package.extras] -dev = ["coverage", "pytest-runner", "pytest-trio", "pytest (>=3)", "sphinx", "tox"] -docs = ["sphinx"] - -[[package]] -category = "dev" -description = "Atomic file writes." -marker = "sys_platform == \"win32\"" -name = "atomicwrites" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "1.4.0" - -[[package]] -category = "dev" -description = "Classes Without Boilerplate" -name = "attrs" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "19.3.0" - -[package.extras] -azure-pipelines = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "pytest-azurepipelines"] -dev = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "pre-commit"] -docs = ["sphinx", "zope.interface"] -tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] - -[[package]] -category = "main" -description = "Internationalization utilities" -name = "babel" -optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.8.0" - -[package.dependencies] -pytz = ">=2015.7" - -[[package]] -category = "main" -description = "Python package for providing Mozilla's CA Bundle." -name = "certifi" -optional = false -python-versions = "*" -version = "2020.6.20" - -[[package]] -category = "dev" -description = "Validate configuration and produce human readable error messages." -name = "cfgv" -optional = false -python-versions = ">=3.6.1" -version = "3.1.0" - -[[package]] -category = "main" -description = "Universal encoding detector for Python 2 and 3" -name = "chardet" -optional = false -python-versions = "*" -version = "3.0.4" - -[[package]] -category = "dev" -description = "Hosted coverage reports for GitHub, Bitbucket and Gitlab" -name = "codecov" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.1.8" - -[package.dependencies] -coverage = "*" -requests = ">=2.7.9" - -[[package]] -category = "main" -description = "Cross-platform colored terminal text." -marker = "sys_platform == \"win32\" or platform_system == \"Windows\"" -name = "colorama" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "0.4.3" - -[[package]] -category = "dev" -description = "Code coverage measurement for Python" -name = "coverage" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" -version = "5.2.1" - -[package.extras] -toml = ["toml"] - -[[package]] -category = "dev" -description = "Distribution utilities" -name = "distlib" -optional = false -python-versions = "*" -version = "0.3.1" - -[[package]] -category = "main" -description = "Docutils -- Python Documentation Utilities" -name = "docutils" -optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "0.16" - -[[package]] -category = "dev" -description = "A platform independent file lock." -name = "filelock" -optional = false -python-versions = "*" -version = "3.0.12" - -[[package]] -category = "dev" -description = "File identification library for Python" -name = "identify" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" -version = "1.4.25" - -[package.extras] -license = ["editdistance"] - -[[package]] -category = "main" -description = "Internationalized Domain Names in Applications (IDNA)" -name = "idna" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.10" - -[[package]] -category = "main" -description = "Getting image size from png/jpeg/jpeg2000/gif file" -name = "imagesize" -optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "1.2.0" - -[[package]] -category = "main" -description = "Read metadata from Python packages" -name = "importlib-metadata" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" -version = "1.7.0" - -[package.dependencies] -zipp = ">=0.5" - -[package.extras] -docs = ["sphinx", "rst.linker"] -testing = ["packaging", "pep517", "importlib-resources (>=1.3)"] - -[[package]] -category = "main" -description = "A very fast and expressive template engine." -name = "jinja2" -optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "2.11.2" - -[package.dependencies] -MarkupSafe = ">=0.23" - -[package.extras] -i18n = ["Babel (>=0.8)"] - -[[package]] -category = "main" -description = "Markdown and reStructuredText in a single file." -name = "m2r" -optional = true -python-versions = "*" -version = "0.2.1" - -[package.dependencies] -docutils = "*" -mistune = "*" - -[[package]] -category = "main" -description = "Safely add untrusted strings to HTML/XML markup." -name = "markupsafe" -optional = true -python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" -version = "1.1.1" - -[[package]] -category = "main" -description = "The fastest markdown parser in pure Python" -name = "mistune" -optional = true -python-versions = "*" -version = "0.8.4" - -[[package]] -category = "dev" -description = "More routines for operating on iterables, beyond itertools" -name = "more-itertools" -optional = false -python-versions = ">=3.5" -version = "8.4.0" - -[[package]] -category = "dev" -description = "Node.js virtual environment builder" -name = "nodeenv" -optional = false -python-versions = "*" -version = "1.4.0" - -[[package]] -category = "main" -description = "Core utilities for Python packages" -name = "packaging" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "20.4" - -[package.dependencies] -pyparsing = ">=2.0.2" -six = "*" - -[[package]] -category = "dev" -description = "plugin and hook calling mechanisms for python" -name = "pluggy" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "0.13.1" - -[package.dependencies] -[package.dependencies.importlib-metadata] -python = "<3.8" -version = ">=0.12" - -[package.extras] -dev = ["pre-commit", "tox"] - -[[package]] -category = "dev" -description = "A framework for managing and maintaining multi-language pre-commit hooks." -name = "pre-commit" -optional = false -python-versions = ">=3.6.1" -version = "2.6.0" - -[package.dependencies] -cfgv = ">=2.0.0" -identify = ">=1.0.0" -nodeenv = ">=0.11.1" -pyyaml = ">=5.1" -toml = "*" -virtualenv = ">=20.0.8" - -[package.dependencies.importlib-metadata] -python = "<3.8" -version = "*" - -[[package]] -category = "dev" -description = "library with cross-python path, ini-parsing, io, code, log facilities" -name = "py" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "1.9.0" - -[[package]] -category = "main" -description = "Pygments is a syntax highlighting package written in Python." -name = "pygments" -optional = true -python-versions = ">=3.5" -version = "2.6.1" - -[[package]] -category = "main" -description = "Python parsing module" -name = "pyparsing" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -version = "2.4.7" - -[[package]] -category = "dev" -description = "pytest: simple powerful testing with Python" -name = "pytest" -optional = false -python-versions = ">=3.5" -version = "5.4.3" - -[package.dependencies] -atomicwrites = ">=1.0" -attrs = ">=17.4.0" -colorama = "*" -more-itertools = ">=4.0.0" -packaging = "*" -pluggy = ">=0.12,<1.0" -py = ">=1.5.0" -wcwidth = "*" - -[package.dependencies.importlib-metadata] -python = "<3.8" -version = ">=0.12" - -[package.extras] -checkqa-mypy = ["mypy (v0.761)"] -testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] - -[[package]] -category = "dev" -description = "Pytest support for asyncio." -name = "pytest-asyncio" -optional = false -python-versions = ">= 3.5" -version = "0.14.0" - -[package.dependencies] -pytest = ">=5.4.0" - -[package.extras] -testing = ["async-generator (>=1.3)", "coverage", "hypothesis (>=5.7.1)"] - -[[package]] -category = "dev" -description = "Formatting PyTest output for Azure Pipelines UI" -name = "pytest-azurepipelines" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "0.8.0" - -[package.dependencies] -pytest = ">=3.5.0" - -[[package]] -category = "dev" -description = "Pytest plugin for measuring coverage." -name = "pytest-cov" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "2.10.0" - -[package.dependencies] -coverage = ">=4.4" -pytest = ">=4.6" - -[package.extras] -testing = ["fields", "hunter", "process-tests (2.0.2)", "six", "pytest-xdist", "virtualenv"] - -[[package]] -category = "dev" -description = "Thin-wrapper around the mock package for easier use with pytest" -name = "pytest-mock" -optional = false -python-versions = ">=3.5" -version = "3.2.0" - -[package.dependencies] -pytest = ">=2.7" - -[package.extras] -dev = ["pre-commit", "tox", "pytest-asyncio"] - -[[package]] -category = "dev" -description = "pytest-sugar is a plugin for pytest that changes the default look and feel of pytest (e.g. progressbar, show tests that fail instantly)." -name = "pytest-sugar" -optional = false -python-versions = "*" -version = "0.9.4" - -[package.dependencies] -packaging = ">=14.1" -pytest = ">=2.9" -termcolor = ">=1.1.0" - -[[package]] -category = "main" -description = "World timezone definitions, modern and historical" -name = "pytz" -optional = true -python-versions = "*" -version = "2020.1" - -[[package]] -category = "dev" -description = "YAML parser and emitter for Python" -name = "pyyaml" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "5.3.1" - -[[package]] -category = "main" -description = "Python HTTP for Humans." -name = "requests" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "2.24.0" - -[package.dependencies] -certifi = ">=2017.4.17" -chardet = ">=3.0.2,<4" -idna = ">=2.5,<3" -urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26" - -[package.extras] -security = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)"] -socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7)", "win-inet-pton"] - -[[package]] -category = "main" -description = "Python 2 and 3 compatibility utilities" -name = "six" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -version = "1.15.0" - -[[package]] -category = "main" -description = "Sniff out which async library your code is running under" -name = "sniffio" -optional = false -python-versions = ">=3.5" -version = "1.1.0" - -[[package]] -category = "main" -description = "This package provides 26 stemmers for 25 languages generated from Snowball algorithms." -name = "snowballstemmer" -optional = true -python-versions = "*" -version = "2.0.0" - -[[package]] -category = "main" -description = "Python documentation generator" -name = "sphinx" -optional = true -python-versions = ">=3.5" -version = "3.1.2" - -[package.dependencies] -Jinja2 = ">=2.3" -Pygments = ">=2.0" -alabaster = ">=0.7,<0.8" -babel = ">=1.3" -colorama = ">=0.3.5" -docutils = ">=0.12" -imagesize = "*" -packaging = "*" -requests = ">=2.5.0" -setuptools = "*" -snowballstemmer = ">=1.1" -sphinxcontrib-applehelp = "*" -sphinxcontrib-devhelp = "*" -sphinxcontrib-htmlhelp = "*" -sphinxcontrib-jsmath = "*" -sphinxcontrib-qthelp = "*" -sphinxcontrib-serializinghtml = "*" - -[package.extras] -docs = ["sphinxcontrib-websupport"] -lint = ["flake8 (>=3.5.0)", "flake8-import-order", "mypy (>=0.780)", "docutils-stubs"] -test = ["pytest", "pytest-cov", "html5lib", "typed-ast", "cython"] - -[[package]] -category = "main" -description = "Read the Docs theme for Sphinx" -name = "sphinx-rtd-theme" -optional = true -python-versions = "*" -version = "0.5.0" - -[package.dependencies] -sphinx = "*" - -[package.extras] -dev = ["transifex-client", "sphinxcontrib-httpdomain", "bump2version"] - -[[package]] -category = "main" -description = "sphinxcontrib-applehelp is a sphinx extension which outputs Apple help books" -name = "sphinxcontrib-applehelp" -optional = true -python-versions = ">=3.5" -version = "1.0.2" - -[package.extras] -lint = ["flake8", "mypy", "docutils-stubs"] -test = ["pytest"] - -[[package]] -category = "main" -description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." -name = "sphinxcontrib-devhelp" -optional = true -python-versions = ">=3.5" -version = "1.0.2" - -[package.extras] -lint = ["flake8", "mypy", "docutils-stubs"] -test = ["pytest"] - -[[package]] -category = "main" -description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" -name = "sphinxcontrib-htmlhelp" -optional = true -python-versions = ">=3.5" -version = "1.0.3" - -[package.extras] -lint = ["flake8", "mypy", "docutils-stubs"] -test = ["pytest", "html5lib"] - -[[package]] -category = "main" -description = "A sphinx extension which renders display math in HTML via JavaScript" -name = "sphinxcontrib-jsmath" -optional = true -python-versions = ">=3.5" -version = "1.0.1" - -[package.extras] -test = ["pytest", "flake8", "mypy"] - -[[package]] -category = "main" -description = "Sphinx extension to include program output" -name = "sphinxcontrib-programoutput" -optional = true -python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" -version = "0.16" - -[package.dependencies] -Sphinx = ">=1.7.0" - -[[package]] -category = "main" -description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." -name = "sphinxcontrib-qthelp" -optional = true -python-versions = ">=3.5" -version = "1.0.3" - -[package.extras] -lint = ["flake8", "mypy", "docutils-stubs"] -test = ["pytest"] - -[[package]] -category = "main" -description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." -name = "sphinxcontrib-serializinghtml" -optional = true -python-versions = ">=3.5" -version = "1.1.4" - -[package.extras] -lint = ["flake8", "mypy", "docutils-stubs"] -test = ["pytest"] - -[[package]] -category = "dev" -description = "ANSII Color formatting for output in terminal." -name = "termcolor" -optional = false -python-versions = "*" -version = "1.1.0" - -[[package]] -category = "dev" -description = "Python Library for Tom's Obvious, Minimal Language" -name = "toml" -optional = false -python-versions = "*" -version = "0.10.1" - -[[package]] -category = "dev" -description = "tox is a generic virtualenv management and test command line tool" -name = "tox" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" -version = "3.18.1" - -[package.dependencies] -colorama = ">=0.4.1" -filelock = ">=3.0.0" -packaging = ">=14" -pluggy = ">=0.12.0" -py = ">=1.4.17" -six = ">=1.14.0" -toml = ">=0.9.4" -virtualenv = ">=16.0.0,<20.0.0 || >20.0.0,<20.0.1 || >20.0.1,<20.0.2 || >20.0.2,<20.0.3 || >20.0.3,<20.0.4 || >20.0.4,<20.0.5 || >20.0.5,<20.0.6 || >20.0.6,<20.0.7 || >20.0.7" - -[package.dependencies.importlib-metadata] -python = "<3.8" -version = ">=0.12,<2" - -[package.extras] -docs = ["pygments-github-lexers (>=0.0.5)", "sphinx (>=2.0.0)", "sphinxcontrib-autoprogram (>=0.1.5)", "towncrier (>=18.5.0)"] -testing = ["flaky (>=3.4.0)", "freezegun (>=0.3.11)", "pathlib2 (>=2.3.3)", "psutil (>=5.6.1)", "pytest (>=4.0.0)", "pytest-cov (>=2.5.1)", "pytest-mock (>=1.10.0)", "pytest-randomly (>=1.0.0)", "pytest-xdist (>=1.22.2)"] - -[[package]] -category = "main" -description = "HTTP library with thread-safe connection pooling, file post, and more." -name = "urllib3" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" -version = "1.25.10" - -[package.extras] -brotli = ["brotlipy (>=0.6.0)"] -secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "pyOpenSSL (>=0.14)", "ipaddress"] -socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"] - -[[package]] -category = "dev" -description = "Virtual Python Environment builder" -name = "virtualenv" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" -version = "20.0.28" - -[package.dependencies] -appdirs = ">=1.4.3,<2" -distlib = ">=0.3.1,<1" -filelock = ">=3.0.0,<4" -six = ">=1.9.0,<2" - -[package.dependencies.importlib-metadata] -python = "<3.8" -version = ">=0.12,<2" - -[package.extras] -docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"] -testing = ["coverage (>=5)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "pytest-xdist (>=1.31.0)", "packaging (>=20.0)", "xonsh (>=0.9.16)"] - -[[package]] -category = "dev" -description = "# Voluptuous is a Python data validation library" -name = "voluptuous" -optional = false -python-versions = "*" -version = "0.11.7" - -[[package]] -category = "dev" -description = "Measures the displayed width of unicode strings in a terminal" -name = "wcwidth" -optional = false -python-versions = "*" -version = "0.2.5" - -[[package]] -category = "dev" -description = "A rewrite of the builtin doctest module" -name = "xdoctest" -optional = false -python-versions = "*" -version = "0.13.0" - -[package.dependencies] -six = "*" - -[package.extras] -all = ["six", "pytest", "pytest-cov", "codecov", "scikit-build", "cmake", "ninja", "pybind11", "pygments", "colorama"] -optional = ["pygments", "colorama"] -tests = ["pytest", "pytest-cov", "codecov", "scikit-build", "cmake", "ninja", "pybind11"] - -[[package]] -category = "main" -description = "Backport of pathlib-compatible object wrapper for zip files" -name = "zipp" -optional = false -python-versions = ">=3.6" -version = "3.1.0" - -[package.extras] -docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] -testing = ["jaraco.itertools", "func-timeout"] - -[extras] -docs = ["sphinx", "sphinx_rtd_theme", "m2r", "sphinxcontrib-programoutput"] - -[metadata] -content-hash = "c73c14c7f8588e3be3cd04a1b8cdcbcc32f2d042d8e30b58b7084b2b544ddb90" -python-versions = "^3.7" - -[metadata.files] -alabaster = [ - {file = "alabaster-0.7.12-py2.py3-none-any.whl", hash = "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359"}, - {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"}, -] -anyio = [ - {file = "anyio-1.4.0-py3-none-any.whl", hash = "sha256:9ee67e8131853f42957e214d4531cee6f2b66dda164a298d9686a768b7161a4f"}, - {file = "anyio-1.4.0.tar.gz", hash = "sha256:95f60964fc4583f3f226f8dc275dfb02aefe7b39b85a999c6d14f4ec5323c1d8"}, -] -appdirs = [ - {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, - {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, -] -async-generator = [ - {file = "async_generator-1.10-py3-none-any.whl", hash = "sha256:01c7bf666359b4967d2cda0000cc2e4af16a0ae098cbffcb8472fb9e8ad6585b"}, - {file = "async_generator-1.10.tar.gz", hash = "sha256:6ebb3d106c12920aaae42ccb6f787ef5eefdcdd166ea3d628fa8476abe712144"}, -] -asyncclick = [ - {file = "asyncclick-7.0.9.tar.gz", hash = "sha256:62cebf3eca36d973802e2dd521ca1db11c5bf4544e9795e093d1a53cb688a8c2"}, -] -atomicwrites = [ - {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, - {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, -] -attrs = [ - {file = "attrs-19.3.0-py2.py3-none-any.whl", hash = "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c"}, - {file = "attrs-19.3.0.tar.gz", hash = "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"}, -] -babel = [ - {file = "Babel-2.8.0-py2.py3-none-any.whl", hash = "sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4"}, - {file = "Babel-2.8.0.tar.gz", hash = "sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38"}, -] -certifi = [ - {file = "certifi-2020.6.20-py2.py3-none-any.whl", hash = "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41"}, - {file = "certifi-2020.6.20.tar.gz", hash = "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3"}, -] -cfgv = [ - {file = "cfgv-3.1.0-py2.py3-none-any.whl", hash = "sha256:1ccf53320421aeeb915275a196e23b3b8ae87dea8ac6698b1638001d4a486d53"}, - {file = "cfgv-3.1.0.tar.gz", hash = "sha256:c8e8f552ffcc6194f4e18dd4f68d9aef0c0d58ae7e7be8c82bee3c5e9edfa513"}, -] -chardet = [ - {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"}, - {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"}, -] -codecov = [ - {file = "codecov-2.1.8-py2.py3-none-any.whl", hash = "sha256:65e8a8008e43eb45a9404bf68f8d4a60d36de3827ef2287971c94940128eba1e"}, - {file = "codecov-2.1.8-py3.8.egg", hash = "sha256:fa7985ac6a3886cf68e3420ee1b5eb4ed30c4bdceec0f332d17ab69f545fbc90"}, - {file = "codecov-2.1.8.tar.gz", hash = "sha256:0be9cd6358cc6a3c01a1586134b0fb524dfa65ccbec3a40e9f28d5f976676ba2"}, -] -colorama = [ - {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"}, - {file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"}, -] -coverage = [ - {file = "coverage-5.2.1-cp27-cp27m-macosx_10_13_intel.whl", hash = "sha256:40f70f81be4d34f8d491e55936904db5c527b0711b2a46513641a5729783c2e4"}, - {file = "coverage-5.2.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:675192fca634f0df69af3493a48224f211f8db4e84452b08d5fcebb9167adb01"}, - {file = "coverage-5.2.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:2fcc8b58953d74d199a1a4d633df8146f0ac36c4e720b4a1997e9b6327af43a8"}, - {file = "coverage-5.2.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:64c4f340338c68c463f1b56e3f2f0423f7b17ba6c3febae80b81f0e093077f59"}, - {file = "coverage-5.2.1-cp27-cp27m-win32.whl", hash = "sha256:52f185ffd3291196dc1aae506b42e178a592b0b60a8610b108e6ad892cfc1bb3"}, - {file = "coverage-5.2.1-cp27-cp27m-win_amd64.whl", hash = "sha256:30bc103587e0d3df9e52cd9da1dd915265a22fad0b72afe54daf840c984b564f"}, - {file = "coverage-5.2.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:9ea749fd447ce7fb1ac71f7616371f04054d969d412d37611716721931e36efd"}, - {file = "coverage-5.2.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ce7866f29d3025b5b34c2e944e66ebef0d92e4a4f2463f7266daa03a1332a651"}, - {file = "coverage-5.2.1-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:4869ab1c1ed33953bb2433ce7b894a28d724b7aa76c19b11e2878034a4e4680b"}, - {file = "coverage-5.2.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:a3ee9c793ffefe2944d3a2bd928a0e436cd0ac2d9e3723152d6fd5398838ce7d"}, - {file = "coverage-5.2.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:28f42dc5172ebdc32622a2c3f7ead1b836cdbf253569ae5673f499e35db0bac3"}, - {file = "coverage-5.2.1-cp35-cp35m-win32.whl", hash = "sha256:e26c993bd4b220429d4ec8c1468eca445a4064a61c74ca08da7429af9bc53bb0"}, - {file = "coverage-5.2.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4186fc95c9febeab5681bc3248553d5ec8c2999b8424d4fc3a39c9cba5796962"}, - {file = "coverage-5.2.1-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:b360d8fd88d2bad01cb953d81fd2edd4be539df7bfec41e8753fe9f4456a5082"}, - {file = "coverage-5.2.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:1adb6be0dcef0cf9434619d3b892772fdb48e793300f9d762e480e043bd8e716"}, - {file = "coverage-5.2.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:098a703d913be6fbd146a8c50cc76513d726b022d170e5e98dc56d958fd592fb"}, - {file = "coverage-5.2.1-cp36-cp36m-win32.whl", hash = "sha256:962c44070c281d86398aeb8f64e1bf37816a4dfc6f4c0f114756b14fc575621d"}, - {file = "coverage-5.2.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b1ed2bdb27b4c9fc87058a1cb751c4df8752002143ed393899edb82b131e0546"}, - {file = "coverage-5.2.1-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:c890728a93fffd0407d7d37c1e6083ff3f9f211c83b4316fae3778417eab9811"}, - {file = "coverage-5.2.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:538f2fd5eb64366f37c97fdb3077d665fa946d2b6d95447622292f38407f9258"}, - {file = "coverage-5.2.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:27ca5a2bc04d68f0776f2cdcb8bbd508bbe430a7bf9c02315cd05fb1d86d0034"}, - {file = "coverage-5.2.1-cp37-cp37m-win32.whl", hash = "sha256:aab75d99f3f2874733946a7648ce87a50019eb90baef931698f96b76b6769a46"}, - {file = "coverage-5.2.1-cp37-cp37m-win_amd64.whl", hash = "sha256:c2ff24df02a125b7b346c4c9078c8936da06964cc2d276292c357d64378158f8"}, - {file = "coverage-5.2.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:304fbe451698373dc6653772c72c5d5e883a4aadaf20343592a7abb2e643dae0"}, - {file = "coverage-5.2.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:c96472b8ca5dc135fb0aa62f79b033f02aa434fb03a8b190600a5ae4102df1fd"}, - {file = "coverage-5.2.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8505e614c983834239f865da2dd336dcf9d72776b951d5dfa5ac36b987726e1b"}, - {file = "coverage-5.2.1-cp38-cp38-win32.whl", hash = "sha256:700997b77cfab016533b3e7dbc03b71d33ee4df1d79f2463a318ca0263fc29dd"}, - {file = "coverage-5.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:46794c815e56f1431c66d81943fa90721bb858375fb36e5903697d5eef88627d"}, - {file = "coverage-5.2.1-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:16042dc7f8e632e0dcd5206a5095ebd18cb1d005f4c89694f7f8aafd96dd43a3"}, - {file = "coverage-5.2.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:c1bbb628ed5192124889b51204de27c575b3ffc05a5a91307e7640eff1d48da4"}, - {file = "coverage-5.2.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:4f6428b55d2916a69f8d6453e48a505c07b2245653b0aa9f0dee38785939f5e4"}, - {file = "coverage-5.2.1-cp39-cp39-win32.whl", hash = "sha256:9e536783a5acee79a9b308be97d3952b662748c4037b6a24cbb339dc7ed8eb89"}, - {file = "coverage-5.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:b8f58c7db64d8f27078cbf2a4391af6aa4e4767cc08b37555c4ae064b8558d9b"}, - {file = "coverage-5.2.1.tar.gz", hash = "sha256:a34cb28e0747ea15e82d13e14de606747e9e484fb28d63c999483f5d5188e89b"}, -] -distlib = [ - {file = "distlib-0.3.1-py2.py3-none-any.whl", hash = "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb"}, - {file = "distlib-0.3.1.zip", hash = "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1"}, -] -docutils = [ - {file = "docutils-0.16-py2.py3-none-any.whl", hash = "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af"}, - {file = "docutils-0.16.tar.gz", hash = "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"}, -] -filelock = [ - {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"}, - {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"}, -] -identify = [ - {file = "identify-1.4.25-py2.py3-none-any.whl", hash = "sha256:ccd88716b890ecbe10920659450a635d2d25de499b9a638525a48b48261d989b"}, - {file = "identify-1.4.25.tar.gz", hash = "sha256:110ed090fec6bce1aabe3c72d9258a9de82207adeaa5a05cd75c635880312f9a"}, -] -idna = [ - {file = "idna-2.10-py2.py3-none-any.whl", hash = "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0"}, - {file = "idna-2.10.tar.gz", hash = "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6"}, -] -imagesize = [ - {file = "imagesize-1.2.0-py2.py3-none-any.whl", hash = "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1"}, - {file = "imagesize-1.2.0.tar.gz", hash = "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"}, -] -importlib-metadata = [ - {file = "importlib_metadata-1.7.0-py2.py3-none-any.whl", hash = "sha256:dc15b2969b4ce36305c51eebe62d418ac7791e9a157911d58bfb1f9ccd8e2070"}, - {file = "importlib_metadata-1.7.0.tar.gz", hash = "sha256:90bb658cdbbf6d1735b6341ce708fc7024a3e14e99ffdc5783edea9f9b077f83"}, -] -jinja2 = [ - {file = "Jinja2-2.11.2-py2.py3-none-any.whl", hash = "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035"}, - {file = "Jinja2-2.11.2.tar.gz", hash = "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0"}, -] -m2r = [ - {file = "m2r-0.2.1.tar.gz", hash = "sha256:bf90bad66cda1164b17e5ba4a037806d2443f2a4d5ddc9f6a5554a0322aaed99"}, -] -markupsafe = [ - {file = "MarkupSafe-1.1.1-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161"}, - {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"}, - {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183"}, - {file = "MarkupSafe-1.1.1-cp27-cp27m-win32.whl", hash = "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b"}, - {file = "MarkupSafe-1.1.1-cp27-cp27m-win_amd64.whl", hash = "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e"}, - {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f"}, - {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-macosx_10_6_intel.whl", hash = "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-win32.whl", hash = "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-win_amd64.whl", hash = "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-win32.whl", hash = "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-win_amd64.whl", hash = "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-win32.whl", hash = "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-win32.whl", hash = "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-win32.whl", hash = "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"}, - {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, -] -mistune = [ - {file = "mistune-0.8.4-py2.py3-none-any.whl", hash = "sha256:88a1051873018da288eee8538d476dffe1262495144b33ecb586c4ab266bb8d4"}, - {file = "mistune-0.8.4.tar.gz", hash = "sha256:59a3429db53c50b5c6bcc8a07f8848cb00d7dc8bdb431a4ab41920d201d4756e"}, -] -more-itertools = [ - {file = "more-itertools-8.4.0.tar.gz", hash = "sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5"}, - {file = "more_itertools-8.4.0-py3-none-any.whl", hash = "sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2"}, -] -nodeenv = [ - {file = "nodeenv-1.4.0-py2.py3-none-any.whl", hash = "sha256:4b0b77afa3ba9b54f4b6396e60b0c83f59eaeb2d63dc3cc7a70f7f4af96c82bc"}, -] -packaging = [ - {file = "packaging-20.4-py2.py3-none-any.whl", hash = "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"}, - {file = "packaging-20.4.tar.gz", hash = "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8"}, -] -pluggy = [ - {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, - {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, -] -pre-commit = [ - {file = "pre_commit-2.6.0-py2.py3-none-any.whl", hash = "sha256:e8b1315c585052e729ab7e99dcca5698266bedce9067d21dc909c23e3ceed626"}, - {file = "pre_commit-2.6.0.tar.gz", hash = "sha256:1657663fdd63a321a4a739915d7d03baedd555b25054449090f97bb0cb30a915"}, -] -py = [ - {file = "py-1.9.0-py2.py3-none-any.whl", hash = "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2"}, - {file = "py-1.9.0.tar.gz", hash = "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342"}, -] -pygments = [ - {file = "Pygments-2.6.1-py3-none-any.whl", hash = "sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324"}, - {file = "Pygments-2.6.1.tar.gz", hash = "sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44"}, -] -pyparsing = [ - {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, - {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, -] -pytest = [ - {file = "pytest-5.4.3-py3-none-any.whl", hash = "sha256:5c0db86b698e8f170ba4582a492248919255fcd4c79b1ee64ace34301fb589a1"}, - {file = "pytest-5.4.3.tar.gz", hash = "sha256:7979331bfcba207414f5e1263b5a0f8f521d0f457318836a7355531ed1a4c7d8"}, -] -pytest-asyncio = [ - {file = "pytest-asyncio-0.14.0.tar.gz", hash = "sha256:9882c0c6b24429449f5f969a5158b528f39bde47dc32e85b9f0403965017e700"}, - {file = "pytest_asyncio-0.14.0-py3-none-any.whl", hash = "sha256:2eae1e34f6c68fc0a9dc12d4bea190483843ff4708d24277c41568d6b6044f1d"}, -] -pytest-azurepipelines = [ - {file = "pytest-azurepipelines-0.8.0.tar.gz", hash = "sha256:944ae2c0790b792d123aa7312fe307bc35214dd26531728923ae5085a1d1feab"}, - {file = "pytest_azurepipelines-0.8.0-py3-none-any.whl", hash = "sha256:38b841a90e88d1966715966d7ea35619ed710386138a6a0b8fb5954c991ca4f1"}, -] -pytest-cov = [ - {file = "pytest-cov-2.10.0.tar.gz", hash = "sha256:1a629dc9f48e53512fcbfda6b07de490c374b0c83c55ff7a1720b3fccff0ac87"}, - {file = "pytest_cov-2.10.0-py2.py3-none-any.whl", hash = "sha256:6e6d18092dce6fad667cd7020deed816f858ad3b49d5b5e2b1cc1c97a4dba65c"}, -] -pytest-mock = [ - {file = "pytest-mock-3.2.0.tar.gz", hash = "sha256:7122d55505d5ed5a6f3df940ad174b3f606ecae5e9bc379569cdcbd4cd9d2b83"}, - {file = "pytest_mock-3.2.0-py3-none-any.whl", hash = "sha256:5564c7cd2569b603f8451ec77928083054d8896046830ca763ed68f4112d17c7"}, -] -pytest-sugar = [ - {file = "pytest-sugar-0.9.4.tar.gz", hash = "sha256:b1b2186b0a72aada6859bea2a5764145e3aaa2c1cfbb23c3a19b5f7b697563d3"}, -] -pytz = [ - {file = "pytz-2020.1-py2.py3-none-any.whl", hash = "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed"}, - {file = "pytz-2020.1.tar.gz", hash = "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048"}, -] -pyyaml = [ - {file = "PyYAML-5.3.1-cp27-cp27m-win32.whl", hash = "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f"}, - {file = "PyYAML-5.3.1-cp27-cp27m-win_amd64.whl", hash = "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76"}, - {file = "PyYAML-5.3.1-cp35-cp35m-win32.whl", hash = "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2"}, - {file = "PyYAML-5.3.1-cp35-cp35m-win_amd64.whl", hash = "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c"}, - {file = "PyYAML-5.3.1-cp36-cp36m-win32.whl", hash = "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2"}, - {file = "PyYAML-5.3.1-cp36-cp36m-win_amd64.whl", hash = "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648"}, - {file = "PyYAML-5.3.1-cp37-cp37m-win32.whl", hash = "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"}, - {file = "PyYAML-5.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf"}, - {file = "PyYAML-5.3.1-cp38-cp38-win32.whl", hash = "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97"}, - {file = "PyYAML-5.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee"}, - {file = "PyYAML-5.3.1.tar.gz", hash = "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d"}, -] -requests = [ - {file = "requests-2.24.0-py2.py3-none-any.whl", hash = "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898"}, - {file = "requests-2.24.0.tar.gz", hash = "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b"}, -] -six = [ - {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, - {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, -] -sniffio = [ - {file = "sniffio-1.1.0-py3-none-any.whl", hash = "sha256:20ed6d5b46f8ae136d00b9dcb807615d83ed82ceea6b2058cecb696765246da5"}, - {file = "sniffio-1.1.0.tar.gz", hash = "sha256:8e3810100f69fe0edd463d02ad407112542a11ffdc29f67db2bf3771afb87a21"}, -] -snowballstemmer = [ - {file = "snowballstemmer-2.0.0-py2.py3-none-any.whl", hash = "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0"}, - {file = "snowballstemmer-2.0.0.tar.gz", hash = "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52"}, -] -sphinx = [ - {file = "Sphinx-3.1.2-py3-none-any.whl", hash = "sha256:97dbf2e31fc5684bb805104b8ad34434ed70e6c588f6896991b2fdfd2bef8c00"}, - {file = "Sphinx-3.1.2.tar.gz", hash = "sha256:b9daeb9b39aa1ffefc2809b43604109825300300b987a24f45976c001ba1a8fd"}, -] -sphinx-rtd-theme = [ - {file = "sphinx_rtd_theme-0.5.0-py2.py3-none-any.whl", hash = "sha256:373413d0f82425aaa28fb288009bf0d0964711d347763af2f1b65cafcb028c82"}, - {file = "sphinx_rtd_theme-0.5.0.tar.gz", hash = "sha256:22c795ba2832a169ca301cd0a083f7a434e09c538c70beb42782c073651b707d"}, -] -sphinxcontrib-applehelp = [ - {file = "sphinxcontrib-applehelp-1.0.2.tar.gz", hash = "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58"}, - {file = "sphinxcontrib_applehelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a"}, -] -sphinxcontrib-devhelp = [ - {file = "sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"}, - {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"}, -] -sphinxcontrib-htmlhelp = [ - {file = "sphinxcontrib-htmlhelp-1.0.3.tar.gz", hash = "sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b"}, - {file = "sphinxcontrib_htmlhelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f"}, -] -sphinxcontrib-jsmath = [ - {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, - {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, -] -sphinxcontrib-programoutput = [ - {file = "sphinxcontrib-programoutput-0.16.tar.gz", hash = "sha256:0caaa216d0ad8d2cfa90a9a9dba76820e376da6e3152be28d10aedc09f82a3b0"}, - {file = "sphinxcontrib_programoutput-0.16-py2.py3-none-any.whl", hash = "sha256:8009d1326b89cd029ee477ce32b45c58d92b8504d48811461c3117014a8f4b1e"}, -] -sphinxcontrib-qthelp = [ - {file = "sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72"}, - {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"}, -] -sphinxcontrib-serializinghtml = [ - {file = "sphinxcontrib-serializinghtml-1.1.4.tar.gz", hash = "sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc"}, - {file = "sphinxcontrib_serializinghtml-1.1.4-py2.py3-none-any.whl", hash = "sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a"}, -] -termcolor = [ - {file = "termcolor-1.1.0.tar.gz", hash = "sha256:1d6d69ce66211143803fbc56652b41d73b4a400a2891d7bf7a1cdf4c02de613b"}, -] -toml = [ - {file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"}, - {file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"}, -] -tox = [ - {file = "tox-3.18.1-py2.py3-none-any.whl", hash = "sha256:3d914480c46232c2d1a035482242535a26d76cc299e4fd28980c858463206f45"}, - {file = "tox-3.18.1.tar.gz", hash = "sha256:5c82e40046a91dbc80b6bd08321b13b4380d8ce3bcb5b62616cb17aaddefbb3a"}, -] -urllib3 = [ - {file = "urllib3-1.25.10-py2.py3-none-any.whl", hash = "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461"}, - {file = "urllib3-1.25.10.tar.gz", hash = "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a"}, -] -virtualenv = [ - {file = "virtualenv-20.0.28-py2.py3-none-any.whl", hash = "sha256:8f582a030156282a9ee9d319984b759a232b07f86048c1d6a9e394afa44e78c8"}, - {file = "virtualenv-20.0.28.tar.gz", hash = "sha256:688a61d7976d82b92f7906c367e83bb4b3f0af96f8f75bfcd3da95608fe8ac6c"}, -] -voluptuous = [ - {file = "voluptuous-0.11.7.tar.gz", hash = "sha256:2abc341dbc740c5e2302c7f9b8e2e243194fb4772585b991931cb5b22e9bf456"}, -] -wcwidth = [ - {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, - {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, -] -xdoctest = [ - {file = "xdoctest-0.13.0-py2.py3-none-any.whl", hash = "sha256:de861fd5230a46bd26c054b4981169dd963f813768cb62b62e104e4d2644ac94"}, - {file = "xdoctest-0.13.0.tar.gz", hash = "sha256:4f113a430076561a9d7f31af65b5d5acda62ee06b05cb6894264cb9efb8196ac"}, -] -zipp = [ - {file = "zipp-3.1.0-py3-none-any.whl", hash = "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b"}, - {file = "zipp-3.1.0.tar.gz", hash = "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"}, -] diff --git a/pyproject.toml b/pyproject.toml index 1bdf1716a..a7ea0ad20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,77 +1,196 @@ -[tool.poetry] +[project] name = "python-kasa" -version = "0.4.0.dev1" -description = "Python API for TP-Link Kasa Smarthome devices" -license = "GPL-3.0-or-later" -authors = ["Your Name "] -repository = "https://github.com/python-kasa/python-kasa" +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" }] readme = "README.md" -packages = [ - { include = "kasa" } -] - -[tool.poetry.scripts] -kasa = "kasa.cli:cli" - -[tool.poetry.dependencies] -python = "^3.7" -importlib-metadata = "*" -asyncclick = "^7" - -# required only for docs -sphinx = { version = "^3", optional = true } -m2r = { version = "^0", optional = true } -sphinx_rtd_theme = { version = "^0", optional = true } -sphinxcontrib-programoutput = { version = "^0", optional = true } - -[tool.poetry.dev-dependencies] -pytest = "^5" -pytest-azurepipelines = "^0" -pytest-cov = "^2" -pytest-asyncio = "^0" -pytest-sugar = "*" -pre-commit = "*" -voluptuous = "*" -toml = "*" -tox = "*" -pytest-mock = "^3" -codecov = "^2" -xdoctest = "^0" - -[tool.poetry.extras] -docs = ["sphinx", "sphinx_rtd_theme", "m2r", "sphinxcontrib-programoutput"] - - -[tool.isort] -multi_line_output = 3 -include_trailing_comma = true -force_grid_wrap = 0 -use_parentheses = true -line_length = 88 -known_first_party = "kasa" -known_third_party = ["asyncclick", "pytest", "setuptools", "voluptuous"] +requires-python = ">=3.11,<4.0" +dependencies = [ + "asyncclick>=8.1.7", + "cryptography>=1.9", + "aiohttp>=3", + "tzdata>=2024.2 ; platform_system == 'Windows'", + "mashumaro>=3.14", +] + +classifiers = [ + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] + +[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", +] +shell = ["ptpython", "rich"] + +[project.urls] +"Homepage" = "https://github.com/python-kasa/python-kasa" +"Bug Tracker" = "https://github.com/python-kasa/python-kasa/issues" +"Documentation" = "https://python-kasa.readthedocs.io" +"Repository" = "https://github.com/python-kasa/python-kasa" + +[project.scripts] +kasa = "kasa.cli.__main__:cli" + +[tool.uv] +dev-dependencies = [ + "pytest", + "pytest-cov", + "pytest-asyncio", + "pytest-sugar", + "pre-commit", + "voluptuous", + "toml", + "pytest-mock", + "codecov", + "xdoctest>=1.2.0", + "coverage[toml]", + "pytest-timeout~=2.0", + "pytest-freezer~=0.4", + "mypy~=1.0", + "pytest-xdist>=3.6.1", + "pytest-socket>=0.7.0", + "ruff>=0.9.0", +] + + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.sdist] +include = [ + "/kasa", + "/devtools", + "/docs", + "/tests", + "/CHANGELOG.md", +] + +[tool.hatch.build.targets.wheel] +include = [ + "/kasa", +] [tool.coverage.run] source = ["kasa"] branch = true -omit = ["kasa/tests/*"] [tool.coverage.report] exclude_lines = [ - # ignore abstract methods + # ignore debug logging + "if debug_enabled:", + # Don't complain if tests don't hit defensive assertion code: + "raise AssertionError", "raise NotImplementedError", - "def __repr__" + # Don't complain about missing debug-only code: + "def __repr__", + # Have to re-enable the standard pragma + "pragma: no cover", + # TYPE_CHECKING and @overload blocks are never executed during pytest run + "if TYPE_CHECKING:", + "@overload" ] -[tool.interrogate] -ignore-init-method = true -ignore-magic = true -ignore-private = true -ignore-semiprivate = true -fail-under = 100 -exclude = ['kasa/tests/*'] -verbose = 2 +[tool.pytest.ini_options] +testpaths = [ + "tests", +] +markers = [ + "requires_dummy: test requires dummy data to pass, skipped on real devices", +] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +#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" -[build-system] -requires = ["poetry>=0.12"] -build-backend = "poetry.masonry.api" +[tool.doc8] +paths = ["docs"] +ignore = ["D001"] +ignore-path-errors = ["docs/source/index.rst;D000"] + + +[tool.ruff] +target-version = "py311" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "D", # pydocstyle + "F", # pyflakes + "UP", # pyupgrade + "B", # flake8-bugbear + "SIM", # flake8-simplify + "FA", # flake8-future-annotations + "I", # isort + "S", # bandit + "PT", # flake8-pytest-style + "LOG", # flake8-logging + "G", # flake8-logging-format + "ANN", # annotations +] +ignore = [ + "D105", # Missing docstring in magic method + "D107", # Missing docstring in `__init__` + "ANN003", # Missing type annotation for `**kwargs` + "ANN401", # allow any +] + +[tool.ruff.lint.pydocstyle] +convention = "pep257" + +[tool.ruff.lint.per-file-ignores] +"tests/*.py" = [ + "D100", + "D101", + "D102", + "D103", + "D104", + "S101", # allow asserts + "E501", # ignore line-too-longs + "ANN", # skip for now +] +"docs/source/conf.py" = [ + "D100", + "D103", +] +# Temporary ANN disable +"kasa/cli/*.py" = [ + "ANN", +] +# Temporary ANN disable +"devtools/*.py" = [ + "ANN", +] + + +[tool.mypy] +warn_unused_configs = true # warns if overrides sections unused/mis-spelled + +[[tool.mypy.overrides]] +module = [ "kasa.tests.*", "devtools.*" ] +disable_error_code = "annotation-unchecked" + +[[tool.mypy.overrides]] +module = [ + "devtools.bench.benchmark", + "devtools.parse_pcap", + "devtools.parse_pcap_klap", + "devtools.perftest", + "devtools.create_module_fixtures" +] +disable_error_code = "import-not-found,import-untyped" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..e69de29bb 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..00c3645ed --- /dev/null +++ b/tests/cli/test_hub.py @@ -0,0 +1,53 @@ +import pytest +from pytest_mock import MockerFixture + +from kasa import DeviceType, Module +from kasa.cli.hub import hub + +from ..device_fixtures import hubs, plug_iot + + +@hubs +async def test_hub_pair(dev, mocker: MockerFixture, runner, caplog): + """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, mocker: MockerFixture, runner): + """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, mocker: MockerFixture, runner): + """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..a790286e6 --- /dev/null +++ b/tests/cli/test_vacuum.py @@ -0,0 +1,114 @@ +from pytest_mock import MockerFixture + +from kasa import 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, mocker: MockerFixture, runner): + """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, mocker: MockerFixture, runner): + """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, runner): + """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, mocker: MockerFixture, runner): + """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, mocker: MockerFixture, runner): + """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 new file mode 100644 index 000000000..6162d3af2 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import asyncio +import os +import sys +import warnings +from pathlib import Path +from unittest.mock import MagicMock, patch + +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.transports.basetransport import BaseTransport + +from .device_fixtures import * # noqa: F403 +from .discovery_fixtures import * # noqa: F403 +from .fixtureinfo import fixture_info # noqa: F401 + +# Parametrize tests to run with device both on and off +turn_on = pytest.mark.parametrize("turn_on", [True, False]) + + +def load_fixture(foldername, filename): + """Load a fixture.""" + path = Path(Path(__file__).parent / "fixtures" / foldername / filename) + with path.open() as fdp: + return fdp.read() + + +async def handle_turn_on(dev, turn_on): + if turn_on: + await dev.turn_on() + else: + await dev.turn_off() + + +@pytest.fixture +def dummy_protocol(): + """Return a smart protocol instance with a mocking-ready dummy transport.""" + + class DummyTransport(BaseTransport): + @property + def default_port(self) -> int: + return -1 + + @property + def credentials_hash(self) -> str: + return "dummy hash" + + async def send(self, request: str) -> dict: + return {} + + async def close(self) -> None: + pass + + async def reset(self) -> None: + pass + + transport = DummyTransport(config=DeviceConfig(host="127.0.0.123")) + protocol = SmartProtocol(transport=transport) + with patch.object(protocol, "BACKOFF_SECONDS_AFTER_TIMEOUT", 0): + yield protocol + + +def pytest_configure(): + pytest.fixtures_missing_methods = {} + + +def pytest_sessionfinish(session, exitstatus): + if not pytest.fixtures_missing_methods: + return + msg = "\n" + for fixture, methods in sorted(pytest.fixtures_missing_methods.items()): + method_list = ", ".join(methods) + msg += f"Fixture {fixture} missing: {method_list}\n" + + warnings.warn( + UserWarning(msg), + stacklevel=1, + ) + + +def pytest_addoption(parser): + parser.addoption( + "--ip", action="store", default=None, help="run against device on given ip" + ) + parser.addoption( + "--username", action="store", default=None, help="authentication username" + ) + parser.addoption( + "--password", action="store", default=None, help="authentication password" + ) + + +def pytest_collection_modifyitems(config, items): + if not config.getoption("--ip"): + print("Testing against fixtures.") + # pytest_socket doesn't work properly in windows with asyncio + # fine to disable as other platforms will pickup any issues. + if sys.platform == "win32": + for item in items: + item.add_marker(pytest.mark.enable_socket) + else: + print("Running against ip {}".format(config.getoption("--ip"))) + requires_dummy = pytest.mark.skip( + reason="test requires to be run against dummy data" + ) + for item in items: + if "requires_dummy" in item.keywords: + item.add_marker(requires_dummy) + else: + item.add_marker(pytest.mark.enable_socket) + + +@pytest.fixture(autouse=True, scope="session") +def asyncio_sleep_fixture(request): # noqa: PT004 + """Patch sleep to prevent tests actually waiting.""" + orig_asyncio_sleep = asyncio.sleep + + async def _asyncio_sleep(*_, **__): + await orig_asyncio_sleep(0) + + if request.config.getoption("--ip"): + yield + else: + with patch("asyncio.sleep", side_effect=_asyncio_sleep): + yield + + +@pytest.fixture(autouse=True, scope="session") +def mock_datagram_endpoint(request): # noqa: PT004 + """Mock create_datagram_endpoint so it doesn't perform io.""" + + async def _create_datagram_endpoint(protocol_factory, *_, **__): + protocol = protocol_factory() + transport = MagicMock() + try: + return transport, protocol + finally: + protocol.connection_made(transport) + + if request.config.getoption("--ip"): + yield + else: + with patch( + "asyncio.BaseEventLoop.create_datagram_endpoint", + 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 new file mode 100644 index 000000000..992cbfd97 --- /dev/null +++ b/tests/device_fixtures.py @@ -0,0 +1,594 @@ +from __future__ import annotations + +import os +from collections.abc import AsyncGenerator + +import pytest + +from kasa import ( + Credentials, + Device, + DeviceType, + Discover, +) +from kasa.iot import IotBulb, IotDimmer, IotLightStrip, IotPlug, IotStrip, IotWallSwitch +from kasa.smart import SmartDevice +from kasa.smartcam import SmartCamDevice + +from .fakeprotocol_iot import FakeIotProtocol +from .fakeprotocol_smart import FakeSmartProtocol +from .fakeprotocol_smartcam import FakeSmartCamProtocol +from .fixtureinfo import ( + FIXTURE_DATA, + ComponentFilter, + FixtureInfo, + filter_fixtures, + idgenerator, +) + +# Tapo bulbs +BULBS_SMART_VARIABLE_TEMP = {"L430C", "L430P", "L530E", "L535E", "L930-5"} +BULBS_SMART_LIGHT_STRIP = {"L900-5", "L900-10", "L920-5", "L930-5"} +BULBS_SMART_COLOR = {"L430C", "L430P", "L530E", "L535E", *BULBS_SMART_LIGHT_STRIP} +BULBS_SMART_DIMMABLE = {"L510B", "L510E"} +BULBS_SMART = ( + BULBS_SMART_VARIABLE_TEMP.union(BULBS_SMART_COLOR) + .union(BULBS_SMART_DIMMABLE) + .union(BULBS_SMART_LIGHT_STRIP) +) + +# Kasa (IOT-prefixed) bulbs +BULBS_IOT_LIGHT_STRIP = {"KL400L5", "KL400L10", "KL430", "KL420L5"} +BULBS_IOT_VARIABLE_TEMP = { + "LB120", + "LB130", + "KL120", + "KL125", + "KL130", + "KL135", + "KL430", +} +BULBS_IOT_COLOR = {"LB130", "KL125", "KL130", "KL135", *BULBS_IOT_LIGHT_STRIP} +BULBS_IOT_DIMMABLE = {"KL50", "KL60", "LB100", "LB110", "KL110", "KL110B"} +BULBS_IOT = ( + BULBS_IOT_VARIABLE_TEMP.union(BULBS_IOT_COLOR) + .union(BULBS_IOT_DIMMABLE) + .union(BULBS_IOT_LIGHT_STRIP) +) + +BULBS_VARIABLE_TEMP = {*BULBS_SMART_VARIABLE_TEMP, *BULBS_IOT_VARIABLE_TEMP} +BULBS_COLOR = {*BULBS_SMART_COLOR, *BULBS_IOT_COLOR} + + +LIGHT_STRIPS = {*BULBS_SMART_LIGHT_STRIP, *BULBS_IOT_LIGHT_STRIP} +BULBS = { + *BULBS_IOT, + *BULBS_SMART, +} + + +PLUGS_IOT = { + "HS100", + "HS103", + "HS105", + "HS110", + "EP10", + "EP25", + "KP100", + "KP105", + "KP115", + "KP125", + "KP401", +} +PLUGS_SMART = { + "P105", + "P100", + "P110", + "P110M", + "P115", + "KP125M", + "EP25", + "P125M", + "TP10", + "TP15", +} +PLUGS = { + *PLUGS_IOT, + *PLUGS_SMART, +} +SWITCHES_IOT = { + "HS200", + "HS210", + "KS200", + "KS200M", +} +SWITCHES_SMART = { + "HS200", + "KS205", + "KS225", + "KS240", + "S500", + "S500D", + "S505", + "S505D", + "TS15", +} +SWITCHES = {*SWITCHES_IOT, *SWITCHES_SMART} +STRIPS_IOT = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40"} +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 = { + *DIMMERS_IOT, + *DIMMERS_SMART, +} + +HUBS_SMART = {"H100", "KH100"} +SENSORS_SMART = { + "T310", + "T315", + "T300", + "T100", + "T110", + "S200B", + "S200D", + "S210", + "S220", + "D100C", # needs a home category? +} +THERMOSTATS_SMART = {"KE100"} + +VACUUMS_SMART = {"RV20"} + +WITH_EMETER_IOT = {"EP25", "HS110", "HS300", "KP115", "KP125", *BULBS_IOT} +WITH_EMETER_SMART = {"P110", "P110M", "P115", "KP125M", "EP25", "P304M"} +WITH_EMETER = {*WITH_EMETER_IOT, *WITH_EMETER_SMART} + +DIMMABLE = {*BULBS, *DIMMERS} + +ALL_DEVICES_IOT = ( + BULBS_IOT.union(PLUGS_IOT).union(STRIPS_IOT).union(DIMMERS_IOT).union(SWITCHES_IOT) +) +ALL_DEVICES_SMART = ( + BULBS_SMART.union(PLUGS_SMART) + .union(STRIPS_SMART) + .union(DIMMERS_SMART) + .union(HUBS_SMART) + .union(SENSORS_SMART) + .union(SWITCHES_SMART) + .union(THERMOSTATS_SMART) + .union(VACUUMS_SMART) +) +ALL_DEVICES = ALL_DEVICES_IOT.union(ALL_DEVICES_SMART) + +IP_FIXTURE_CACHE: dict[str, FixtureInfo] = {} + + +def parametrize_combine(parametrized: list[pytest.MarkDecorator]): + """Combine multiple pytest parametrize dev marks into one set of fixtures.""" + fixtures = set() + for param in parametrized: + if param.args[0] != "dev": + raise Exception(f"Supplied mark is not for dev fixture: {param.args[0]}") + fixtures.update(param.args[1]) + return pytest.mark.parametrize( + "dev", + sorted(list(fixtures)), + indirect=True, + ids=idgenerator, + ) + + +def parametrize_subtract(params: pytest.MarkDecorator, subtract: pytest.MarkDecorator): + """Combine multiple pytest parametrize dev marks into one set of fixtures.""" + if params.args[0] != "dev" or subtract.args[0] != "dev": + raise Exception( + f"Supplied mark is not for dev fixture: {params.args[0]} {subtract.args[0]}" + ) + fixtures = [] + for param in params.args[1]: + if param not in subtract.args[1]: + fixtures.append(param) + return pytest.mark.parametrize( + "dev", + sorted(fixtures), + indirect=True, + ids=idgenerator, + ) + + +def parametrize( + desc, + *, + model_filter=None, + protocol_filter=None, + component_filter: str | ComponentFilter | None = None, + data_root_filter=None, + device_type_filter=None, + ids=None, + fixture_name="dev", +): + if ids is None: + ids = idgenerator + return pytest.mark.parametrize( + fixture_name, + filter_fixtures( + desc, + model_filter=model_filter, + protocol_filter=protocol_filter, + component_filter=component_filter, + data_root_filter=data_root_filter, + device_type_filter=device_type_filter, + ), + indirect=True, + ids=ids, + ) + + +has_emeter = parametrize( + "has emeter", model_filter=WITH_EMETER, protocol_filter={"SMART", "IOT"} +) +no_emeter = parametrize( + "no emeter", + 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"} +) +no_emeter_iot = parametrize( + "no emeter iot", + model_filter=ALL_DEVICES_IOT - WITH_EMETER_IOT, + protocol_filter={"IOT"}, +) + +plug = parametrize("plugs", model_filter=PLUGS, protocol_filter={"IOT", "SMART"}) +plug_iot = parametrize("plugs iot", model_filter=PLUGS, protocol_filter={"IOT"}) +wallswitch = parametrize( + "wall switches", model_filter=SWITCHES, protocol_filter={"IOT", "SMART"} +) +wallswitch_iot = parametrize( + "wall switches iot", model_filter=SWITCHES, protocol_filter={"IOT"} +) +strip = parametrize("strips", model_filter=STRIPS, protocol_filter={"SMART", "IOT"}) +dimmer_iot = parametrize("dimmers", model_filter=DIMMERS, protocol_filter={"IOT"}) +lightstrip_iot = parametrize( + "lightstrips", model_filter=LIGHT_STRIPS, protocol_filter={"IOT"} +) + +# bulb types +dimmable_iot = parametrize("dimmable", model_filter=DIMMABLE, protocol_filter={"IOT"}) +non_dimmable_iot = parametrize( + "non-dimmable", model_filter=BULBS - DIMMABLE, protocol_filter={"IOT"} +) +variable_temp = parametrize( + "variable color temp", + model_filter=BULBS_VARIABLE_TEMP, + protocol_filter={"SMART", "IOT"}, +) +non_variable_temp = parametrize( + "non-variable color temp", + model_filter=BULBS - BULBS_VARIABLE_TEMP, + protocol_filter={"SMART", "IOT"}, +) +color_bulb = parametrize( + "color bulbs", model_filter=BULBS_COLOR, protocol_filter={"SMART", "IOT"} +) +non_color_bulb = parametrize( + "non-color bulbs", + model_filter=BULBS - BULBS_COLOR, + protocol_filter={"SMART", "IOT"}, +) + +color_bulb_iot = parametrize( + "color bulbs iot", model_filter=BULBS_IOT_COLOR, protocol_filter={"IOT"} +) +variable_temp_iot = parametrize( + "variable color temp iot", + model_filter=BULBS_IOT_VARIABLE_TEMP, + protocol_filter={"IOT"}, +) +variable_temp_smart = parametrize( + "variable color temp smart", + model_filter=BULBS_SMART_VARIABLE_TEMP, + protocol_filter={"SMART"}, +) + +bulb_smart = parametrize( + "bulb devices smart", + device_type_filter=[DeviceType.Bulb, DeviceType.LightStrip], + protocol_filter={"SMART"}, +) +bulb_iot = parametrize( + "bulb devices iot", model_filter=BULBS_IOT, protocol_filter={"IOT"} +) +bulb = parametrize_combine([bulb_smart, bulb_iot]) + +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"} +) + +plug_smart = parametrize( + "plug devices smart", model_filter=PLUGS_SMART, protocol_filter={"SMART"} +) +switch_smart = parametrize( + "switch devices smart", model_filter=SWITCHES_SMART, protocol_filter={"SMART"} +) +dimmers_smart = parametrize( + "dimmer devices smart", model_filter=DIMMERS_SMART, protocol_filter={"SMART"} +) +hubs_smart = parametrize( + "hubs smart", model_filter=HUBS_SMART, protocol_filter={"SMART"} +) +sensors_smart = parametrize( + "sensors smart", model_filter=SENSORS_SMART, protocol_filter={"SMART.CHILD"} +) +thermostats_smart = parametrize( + "thermostats smart", model_filter=THERMOSTATS_SMART, protocol_filter={"SMART.CHILD"} +) +device_smart = parametrize( + "devices smart", model_filter=ALL_DEVICES_SMART, protocol_filter={"SMART"} +) +device_iot = parametrize( + "devices iot", model_filter=ALL_DEVICES_IOT, protocol_filter={"IOT"} +) +device_smartcam = parametrize("devices smartcam", protocol_filter={"SMARTCAM"}) +camera_smartcam = parametrize( + "camera smartcam", + device_type_filter=[DeviceType.Camera], + 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(): + """Check that every fixture file is categorized.""" + categorized_fixtures = set( + dimmer_iot.args[1] + + strip.args[1] + + plug.args[1] + + bulb.args[1] + + wallswitch.args[1] + + lightstrip_iot.args[1] + + bulb_smart.args[1] + + dimmers_smart.args[1] + + 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: + print(diffs) + for diff in diffs: + print( + f"No category for file {diff.name} protocol {diff.protocol}, add to the corresponding set (BULBS, PLUGS, ..)" + ) + raise Exception(f"Missing category for {diff.name}") + + +check_categories() + + +def device_for_fixture_name(model, protocol): + if protocol in {"SMART", "SMART.CHILD"}: + return SmartDevice + elif protocol in {"SMARTCAM", "SMARTCAM.CHILD"}: + return SmartCamDevice + else: + for d in STRIPS_IOT: + if d in model: + return IotStrip + + for d in PLUGS_IOT: + if d in model: + return IotPlug + for d in SWITCHES_IOT: + if d in model: + return IotWallSwitch + + # Light strips are recognized also as bulbs, so this has to go first + for d in BULBS_IOT_LIGHT_STRIP: + if d in model: + return IotLightStrip + + for d in BULBS_IOT: + if d in model: + return IotBulb + + for d in DIMMERS_IOT: + if d in model: + return IotDimmer + + raise Exception("Unable to find type for %s", model) + + +async def _update_and_close(d) -> Device: + await d.update() + await d.protocol.close() + return d + + +async def _discover_update_and_close(ip, username, password) -> Device: + if username and password: + credentials = Credentials(username=username, password=password) + else: + credentials = None + d = await Discover.discover_single(ip, timeout=10, credentials=credentials) + return await _update_and_close(d) + + +async def get_device_for_fixture( + fixture_data: FixtureInfo, *, verbatim=False, update_after_init=True +) -> Device: + # if the wanted file is not an absolute path, prepend the fixtures directory + + 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 in {"SMARTCAM", "SMARTCAM.CHILD"}: + d.protocol = FakeSmartCamProtocol( + fixture_data.data, fixture_data.name, verbatim=verbatim + ) + else: + d.protocol = FakeIotProtocol(fixture_data.data, verbatim=verbatim) + + discovery_data = None + if "discovery_result" in fixture_data.data: + 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"]} + } + + if discovery_data: # Child devices do not have discovery info + d.update_from_discover_info(discovery_data) + + if update_after_init: + await _update_and_close(d) + return d + + +async def get_device_for_fixture_protocol(fixture, protocol): + finfo = FixtureInfo(name=fixture, protocol=protocol, data={}) + for fixture_info in FIXTURE_DATA: + if finfo == fixture_info: + return await get_device_for_fixture(fixture_info) + + +def get_fixture_info(fixture, protocol): + finfo = FixtureInfo(name=fixture, protocol=protocol, data={}) + for fixture_info in FIXTURE_DATA: + if finfo == fixture_info: + return fixture_info + + +def get_nearest_fixture_to_ip(dev): + if isinstance(dev, SmartDevice): + protocol_fixtures = filter_fixtures("", protocol_filter={"SMART"}) + elif isinstance(dev, SmartCamDevice): + protocol_fixtures = filter_fixtures("", protocol_filter={"SMARTCAM"}) + else: + protocol_fixtures = filter_fixtures("", protocol_filter={"IOT"}) + assert protocol_fixtures, "Unknown device type" + + # This will get the best fixture with a match on model region + 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)) + + # This will get the best fixture based on model starting with the name. + if "(" in dev.model: + model, _, _ = dev.model.partition("(") + else: + model = dev.model + if model_fixtures := filter_fixtures( + "", model_startswith_filter=model, fixture_list=protocol_fixtures + ): + return next(iter(model_fixtures)) + + if device_type_fixtures := filter_fixtures( + "", device_type_filter={dev.device_type}, fixture_list=protocol_fixtures + ): + return next(iter(device_type_fixtures)) + + return next(iter(protocol_fixtures)) + + +@pytest.fixture(params=filter_fixtures("main devices"), ids=idgenerator) +async def dev(request) -> AsyncGenerator[Device, None]: + """Device fixture. + + Provides a device (given --ip) or parametrized fixture for the supported devices. + The initial update is called automatically before returning the device. + """ + fixture_data: FixtureInfo = request.param + dev: Device + + ip = request.config.getoption("--ip") + username = request.config.getoption("--username") or os.environ.get("KASA_USERNAME") + password = request.config.getoption("--password") or os.environ.get("KASA_PASSWORD") + if ip: + fixture = IP_FIXTURE_CACHE.get(ip) + + d = None + if not fixture: + d = await _discover_update_and_close(ip, username, password) + IP_FIXTURE_CACHE[ip] = fixture = get_nearest_fixture_to_ip(d) + assert fixture + if fixture.name != fixture_data.name: + pytest.skip(f"skipping file {fixture_data.name}") + dev = None + else: + dev = d if d else await _discover_update_and_close(ip, username, password) + else: + dev = await get_device_for_fixture(fixture_data) + + yield dev + + if dev: + await dev.disconnect() + + +def get_parent_and_child_modules(device: Device, module_name): + """Return iterator of module if exists on parent and children. + + Useful for testing devices that have components listed on the parent that are only + supported on the children, i.e. ks240. + """ + if module_name in device.modules: + yield device.modules[module_name] + for child in device.children: + if module_name in child.modules: + yield child.modules[module_name] diff --git a/tests/discovery_fixtures.py b/tests/discovery_fixtures.py new file mode 100644 index 000000000..3cf726f48 --- /dev/null +++ b/tests/discovery_fixtures.py @@ -0,0 +1,401 @@ +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 + +import pytest + +from kasa.transports.xortransport import XorEncryption + +from .fakeprotocol_iot import FakeIotProtocol +from .fakeprotocol_smart import FakeSmartProtocol, FakeSmartTransport +from .fakeprotocol_smartcam import FakeSmartCamProtocol +from .fixtureinfo import FixtureInfo, filter_fixtures, idgenerator + +DISCOVERY_MOCK_IP = "127.0.0.123" + + +class DiscoveryResponse(TypedDict): + result: dict[str, Any] + 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, + *, + https: bool = False, + omit_keys: dict[str, Any] | None = None, +) -> DiscoveryResponse: + if omit_keys is None: + omit_keys = {"encrypt_info": None} + result: DiscoveryResponse = { + "result": { + "device_id": "xx", + "owner": "xx", + "device_type": device_family, + "device_model": "P110(EU)", + "ip": "127.0.0.1", + "mac": "48-22xxx", + "is_support_iot_cloud": True, + "obd_src": "tplink", + "factory_default": False, + "mgt_encrypt_schm": { + "is_support_https": https, + "encrypt_type": encrypt_type, + "http_port": 80, + "lv": 2, + }, + "encrypt_info": {"data": "", "key": "", "sym_schm": encrypt_type}, + }, + "error_code": 0, + } + for key, val in omit_keys.items(): + if val is None: + result["result"].pop(key) + else: + result["result"][key].pop(val) + + return result + + +UNSUPPORTED_DEVICES = { + "unknown_device_family": _make_unsupported("SMART.TAPOXMASTREE", "AES"), + "unknown_iot_device_family": _make_unsupported("IOT.IOTXMASTREE", "AES"), + "wrong_encryption_iot": _make_unsupported("IOT.SMARTPLUGSWITCH", "AES"), + "wrong_encryption_smart": _make_unsupported("SMART.TAPOBULB", "IOT"), + "unknown_encryption": _make_unsupported("IOT.SMARTPLUGSWITCH", "FOO"), + "missing_encrypt_type": _make_unsupported( + "SMART.TAPOBULB", + "FOO", + omit_keys={"mgt_encrypt_schm": "encrypt_type", "encrypt_info": None}, + ), + "unable_to_parse": _make_unsupported( + "SMART.TAPOBULB", + "FOO", + omit_keys={"device_id": None}, + ), + "invalidinstance": _make_unsupported( + "IOT.SMARTPLUGSWITCH", + "KLAP", + https=True, + ), + "homewifi": UNSUPPORTED_HOMEWIFISYSTEM, +} + + +def parametrize_discovery( + desc, *, data_root_filter=None, protocol_filter=None, model_filter=None +): + filtered_fixtures = filter_fixtures( + desc, + data_root_filter=data_root_filter, + protocol_filter=protocol_filter, + model_filter=model_filter, + ) + return pytest.mark.parametrize( + "discovery_mock", + filtered_fixtures, + indirect=True, + ids=idgenerator, + ) + + +new_discovery = 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", "SMARTCAM", "IOT"} + ), + ids=idgenerator, +) +async def discovery_mock(request, mocker): + """Mock discovery and patch protocol queries to use Fake protocols.""" + fi: FixtureInfo = request.param + fixture_info = FixtureInfo(fi.name, fi.protocol, copy.deepcopy(fi.data)) + return patch_discovery({DISCOVERY_MOCK_IP: fixture_info}, mocker) + + +def create_discovery_mock(ip: str, fixture_data: dict): + """Mock discovery and patch protocol queries to use Fake protocols.""" + + @dataclass + class _DiscoveryMock: + ip: str + default_port: int + discovery_port: int + discovery_data: dict + query_data: dict + device_type: str + encrypt_type: str + 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: + if self.default_port == 9999: + return XorEncryption.encrypt(json_dumps(self.discovery_data))[4:] + else: + return ( + b"\x02\x00\x00\x01\x01[\x00\x00\x00\x00\x00\x00W\xcev\xf8" + + json_dumps(self.discovery_data).encode() + ) + + if "discovery_result" in fixture_data: + 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") + ) + + 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, + default_port, + 20002, + discovery_data, + fixture_data, + device_type, + encrypt_type, + https, + login_version, + http_port=http_port, + ) + else: + sys_info = fixture_data["system"]["get_sysinfo"] + discovery_data = {"system": {"get_sysinfo": sys_info.copy()}} + device_type = sys_info.get("mic_type") or sys_info.get("type") + encrypt_type = "XOR" + login_version = None + dm = _DiscoveryMock( + ip, + 9999, + 9999, + discovery_data, + fixture_data, + device_type, + encrypt_type, + False, + login_version, + ) + + return dm + + +def patch_discovery(fixture_infos: dict[str, FixtureInfo], mocker): + """Mock discovery and patch protocol queries to use Fake protocols.""" + discovery_mocks = { + ip: create_discovery_mock(ip, fixture_info.data) + for ip, fixture_info in fixture_infos.items() + } + protos = { + ip: FakeSmartProtocol(fixture_info.data, fixture_info.name) + if fixture_info.protocol in {"SMART", "SMART.CHILD"} + else FakeSmartCamProtocol(fixture_info.data, fixture_info.name) + if fixture_info.protocol in {"SMARTCAM", "SMARTCAM.CHILD"} + else FakeIotProtocol(fixture_info.data, fixture_info.name) + for ip, fixture_info in fixture_infos.items() + } + 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] + # Ip of first fixture could have been modified by a test + if dm.ip == first_ip: + # hostname could have been used + host = first_host if first_host else first_ip + else: + host = dm.ip + # update the protos for any host testing or the test overriding the first ip + protos[host] = ( + FakeSmartProtocol(fixture_info.data, fixture_info.name) + if fixture_info.protocol in {"SMART", "SMART.CHILD"} + else FakeSmartCamProtocol(fixture_info.data, fixture_info.name) + if fixture_info.protocol in {"SMARTCAM", "SMARTCAM.CHILD"} + else FakeIotProtocol(fixture_info.data, fixture_info.name) + ) + port = ( + dm.port_override + if dm.port_override and dm.discovery_port != 20002 + else dm.discovery_port + ) + self.datagram_received( + 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 + first_ip = list(discovery_mocks.values())[ + 0 + ].ip # ip could have been overridden in test + return [(None, None, None, None, (first_ip, 0))] + + 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", "SMARTCAM", "IOT"} + ), + ids=idgenerator, +) +def discovery_data(request, mocker): + """Return raw discovery file contents as JSON. Used for discovery tests.""" + fixture_info = request.param + fixture_data = copy.deepcopy(fixture_info.data) + # Add missing queries to fixture data + if "component_nego" in fixture_data: + components = { + comp["id"]: int(comp["ver_code"]) + for comp in fixture_data["component_nego"]["component_list"] + } + for k, v in FakeSmartTransport.FIXTURE_MISSING_MAP.items(): + # Value is a tuple of component,reponse + if k not in fixture_data and v[0] in components: + fixture_data[k] = v[1] + 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 fixture_data["discovery_result"].copy() + else: + return {"system": {"get_sysinfo": fixture_data["system"]["get_sysinfo"]}} + + +@pytest.fixture( + params=UNSUPPORTED_DEVICES.values(), ids=list(UNSUPPORTED_DEVICES.keys()) +) +def unsupported_device_info(request, mocker): + """Return unsupported devices for cli and discovery tests.""" + discovery_data = request.param + host = "127.0.0.1" + + async def mock_discover(self): + if discovery_data: + data = ( + b"\x02\x00\x00\x01\x01[\x00\x00\x00\x00\x00\x00W\xcev\xf8" + + json_dumps(discovery_data).encode() + ) + self.datagram_received(data, (host, 20002)) + + mocker.patch("kasa.discover._DiscoverProtocol.do_discover", mock_discover) + + return discovery_data diff --git a/tests/fakeprotocol_iot.py b/tests/fakeprotocol_iot.py new file mode 100644 index 000000000..ed3b5b102 --- /dev/null +++ b/tests/fakeprotocol_iot.py @@ -0,0 +1,547 @@ +import copy +import logging + +from kasa.deviceconfig import DeviceConfig +from kasa.protocols import IotProtocol +from kasa.transports.basetransport import BaseTransport + +_LOGGER = logging.getLogger(__name__) + + +def get_realtime(obj, x, *args): + return { + "current": 0.268587, + "voltage": 125.836131, + "power": 33.495623, + "total": 0.199000, + } + + +def get_monthstat(obj, x, *args): + if x["year"] < 2016: + return {"month_list": []} + + return { + "month_list": [ + {"year": 2016, "month": 11, "energy": 1.089000}, + {"year": 2016, "month": 12, "energy": 1.582000}, + ] + } + + +def get_daystat(obj, x, *args): + if x["year"] < 2016: + return {"day_list": []} + + return { + "day_list": [ + {"year": 2016, "month": 11, "day": 24, "energy": 0.026000}, + {"year": 2016, "month": 11, "day": 25, "energy": 0.109000}, + ] + } + + +emeter_support = { + "get_realtime": get_realtime, + "get_monthstat": get_monthstat, + "get_daystat": get_daystat, +} + + +def get_realtime_units(obj, x, *args): + return {"power_mw": 10800} + + +def get_monthstat_units(obj, x, *args): + if x["year"] < 2016: + return {"month_list": []} + + return { + "month_list": [ + {"year": 2016, "month": 11, "energy_wh": 32}, + {"year": 2016, "month": 12, "energy_wh": 16}, + ] + } + + +def get_daystat_units(obj, x, *args): + if x["year"] < 2016: + return {"day_list": []} + + return { + "day_list": [ + {"year": 2016, "month": 11, "day": 24, "energy_wh": 20}, + {"year": 2016, "month": 11, "day": 25, "energy_wh": 32}, + ] + } + + +emeter_units_support = { + "get_realtime": get_realtime_units, + "get_monthstat": get_monthstat_units, + "get_daystat": get_daystat_units, +} + + +emeter_commands = { + "emeter": emeter_support, + "smartlife.iot.common.emeter": emeter_units_support, +} + + +def error(msg="default msg"): + return {"err_code": -1323, "msg": msg} + + +def success(res): + if res: + res.update({"err_code": 0}) + else: + res = {"err_code": 0} + return res + + +# plugs and bulbs use a different module for time information, +# so we define the contents here to avoid repeating ourselves +TIME_MODULE = { + "get_time": { + "year": 2017, + "month": 1, + "mday": 2, + "hour": 3, + "min": 4, + "sec": 5, + }, + "get_timezone": { + "zone_str": "test", + "dst_offset": -1, + "index": 12, + "tz_str": "test2", + }, +} + +CLOUD_MODULE = { + "get_info": { + "username": "", + "server": "devs.tplinkcloud.com", + "binded": 0, + "err_code": 0, + "cld_connection": 0, + "illegalType": -1, + "stopConnect": -1, + "tcspStatus": -1, + "fwDlPage": "", + "tcspInfo": "", + "fwNotifyType": 0, + } +} + +SCHEDULE_MODULE = { + "get_next_action": { + "action": 1, + "err_code": 0, + "id": "0794F4729DB271627D1CF35A9A854030", + "schd_time": 68927, + "type": 2, + }, + "get_rules": { + "enable": 1, + "err_code": 0, + "rule_list": [ + { + "eact": -1, + "enable": 1, + "id": "8AA75A50A8440B17941D192BD9E01FFA", + "name": "name", + "repeat": 1, + "sact": 1, + "smin": 1027, + "soffset": 0, + "stime_opt": 2, + "wday": [1, 1, 1, 1, 1, 1, 1], + }, + ], + "version": 2, + }, +} + +AMBIENT_MODULE = { + "get_current_brt": {"value": 26, "err_code": 0}, + "get_config": { + "devs": [ + { + "hw_id": 0, + "enable": 0, + "dark_index": 1, + "min_adc": 0, + "max_adc": 2450, + "level_array": [ + {"name": "cloudy", "adc": 490, "value": 20}, + {"name": "overcast", "adc": 294, "value": 12}, + {"name": "dawn", "adc": 222, "value": 9}, + {"name": "twilight", "adc": 222, "value": 9}, + {"name": "total darkness", "adc": 111, "value": 4}, + {"name": "custom", "adc": 2400, "value": 97}, + ], + } + ], + "ver": "1.0", + "err_code": 0, + }, +} + +MOTION_MODULE = { + "get_adc_value": {"value": 50, "err_code": 0}, + "get_config": { + "enable": 0, + "version": "1.0", + "trigger_index": 2, + "cold_time": 60000, + "min_adc": 0, + "max_adc": 4095, + "array": [80, 50, 20, 0], + "err_code": 0, + }, +} + +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, +} + +DEFAULT_BEHAVIOR = { + "err_code": 0, + "hard_on": {"mode": "circadian"}, + "soft_on": {"mode": "last_status"}, +} + + +class FakeIotProtocol(IotProtocol): + def __init__(self, info, fixture_name=None, *, verbatim=False): + super().__init__( + transport=FakeIotTransport(info, fixture_name, verbatim=verbatim), + ) + + async def query(self, request, retry_count: int = 3): + """Implement query here so tests can still patch IotProtocol.query.""" + resp_dict = await self._query(request, retry_count) + return resp_dict + + +class FakeIotTransport(BaseTransport): + def __init__(self, info, fixture_name=None, *, verbatim=False): + super().__init__(config=DeviceConfig("127.0.0.123")) + info = copy.deepcopy(info) + self.discovery_data = info + self.fixture_name = fixture_name + self.writer = None + self.reader = None + self.verbatim = verbatim + + # When True verbatim will bypass any extra processing of missing + # methods and is used to test the fixture creation itself. + if verbatim: + self.proto = copy.deepcopy(info) + else: + self.proto = self._build_fake_proto(info) + + @staticmethod + def _build_fake_proto(info): + """Create an internal protocol with extra data not in the fixture.""" + proto = copy.deepcopy(FakeIotTransport.baseproto) + + for target in info: + if target != "discovery_result": + for cmd in info[target]: + # Use setdefault in case the fixture has modules not yet + # part of the baseproto. + proto.setdefault(target, {})[cmd] = info[target][cmd] + + # if we have emeter support, we need to add the missing pieces + for module in ["emeter", "smartlife.iot.common.emeter"]: + if ( + module in info + and "err_code" in info[module] + and info[module]["err_code"] != 0 + ): + proto[module] = info[module] + else: + for etype in ["get_realtime", "get_daystat", "get_monthstat"]: + if ( + module in info and etype in info[module] + ): # if the fixture has the data, use it + # print("got %s %s from fixture: %s" % (module, etype, info[module][etype])) + proto[module][etype] = info[module][etype] + else: # otherwise fall back to the static one + dummy_data = emeter_commands[module][etype] + # print("got %s %s from dummy: %s" % (module, etype, dummy_data)) + proto[module][etype] = dummy_data + return proto + + @property + def default_port(self) -> int: + return 9999 + + @property + def credentials_hash(self) -> None: + return None + + def set_alias(self, x, child_ids=None): + if child_ids is None: + child_ids = [] + _LOGGER.debug("Setting alias to %s, child_ids: %s", x["alias"], child_ids) + if child_ids: + for child in self.proto["system"]["get_sysinfo"]["children"]: + if child["id"] in child_ids: + child["alias"] = x["alias"] + else: + self.proto["system"]["get_sysinfo"]["alias"] = x["alias"] + + def set_relay_state(self, x, child_ids=None): + if child_ids is None: + child_ids = [] + _LOGGER.debug("Setting relay state to %s", x["state"]) + + _LOGGER.info("child_ids: %s", child_ids) + if child_ids: + for child in self.proto["system"]["get_sysinfo"]["children"]: + if child["id"] in child_ids: + _LOGGER.info("Found %s, turning to %s", child, x["state"]) + child["state"] = x["state"] + else: + self.proto["system"]["get_sysinfo"]["relay_state"] = x["state"] + + def set_led_off(self, x, *args): + _LOGGER.debug("Setting led off to %s", x) + self.proto["system"]["get_sysinfo"]["led_off"] = x["off"] + + def set_mac(self, x, *args): + _LOGGER.debug("Setting mac to %s", x) + self.proto["system"]["get_sysinfo"]["mac"] = x["mac"] + + def set_hs220_brightness(self, x, *args): + _LOGGER.debug("Setting brightness to %s", x) + self.proto["system"]["get_sysinfo"]["brightness"] = x["brightness"] + + def set_hs220_dimmer_transition(self, x, *args): + _LOGGER.debug("Setting dimmer transition to %s", x) + brightness = x["brightness"] + if brightness == 0: + self.proto["system"]["get_sysinfo"]["relay_state"] = 0 + else: + self.proto["system"]["get_sysinfo"]["relay_state"] = 1 + self.proto["system"]["get_sysinfo"]["brightness"] = x["brightness"] + + def set_lighting_effect(self, effect, *args): + _LOGGER.debug("Setting light effect to %s", effect) + self.proto["system"]["get_sysinfo"]["lighting_effect_state"] = dict(effect) + + def transition_light_state(self, state_changes, *args): + # Setting the light state on a device will turn off any active lighting effects. + # Unless it's just the brightness in which case it will update the brightness for + # the lighting effect + if lighting_effect_state := self.proto["system"]["get_sysinfo"].get( + "lighting_effect_state" + ): + if ( + "hue" in state_changes + or "saturation" in state_changes + or "color_temp" in state_changes + ): + lighting_effect_state["enable"] = 0 + elif ( + lighting_effect_state["enable"] == 1 + and state_changes.get("on_off") != 0 + and (brightness := state_changes.get("brightness")) + ): + lighting_effect_state["brightness"] = brightness + return + + _LOGGER.debug("Setting light state to %s", state_changes) + light_state = self.proto["system"]["get_sysinfo"]["light_state"] + + _LOGGER.debug("Current light state: %s", light_state) + new_state = light_state + + # turn on requested, if we were off, use the dft_on_state as a base + if state_changes["on_off"] == 1 and not light_state["on_off"]: + _LOGGER.debug("Bulb was off, using dft_on_state") + new_state = light_state["dft_on_state"] + + # override the existing settings + new_state.update(state_changes) + + if ( + not state_changes["on_off"] and "dft_on_state" not in light_state + ): # if not already off, pack the data inside dft_on_state + _LOGGER.debug( + "Bulb was on and turn_off was requested, saving to dft_on_state" + ) + new_state = {"dft_on_state": light_state, "on_off": 0} + + _LOGGER.debug("New light state: %s", new_state) + self.proto["system"]["get_sysinfo"]["light_state"] = new_state + + def set_preferred_state(self, new_state, *args): + """Implement set_preferred_state.""" + self.proto["system"]["get_sysinfo"]["preferred_state"][new_state["index"]] = ( + new_state + ) + + def light_state(self, x, *args): + light_state = self.proto["system"]["get_sysinfo"]["light_state"] + # Our tests have light state off, so we simply return the dft_on_state when device is on. + _LOGGER.debug("reporting light state: %s", light_state) + # TODO: hack to go around KL430 fixture differences + if light_state["on_off"] and "dft_on_state" in light_state: + return light_state["dft_on_state"] + else: + return light_state + + def set_time(self, new_state: dict, *args): + """Implement set_time.""" + mods = [ + v + for k, v in self.proto.items() + if k in {"time", "smartlife.iot.common.timesetting"} + ] + index = new_state.pop("index", None) + for mod in mods: + mod["get_time"] = new_state + if index is not None: + mod["get_timezone"]["index"] = index + + baseproto = { + "system": { + "set_relay_state": set_relay_state, + "set_dev_alias": set_alias, + "set_led_off": set_led_off, + "get_dev_icon": {"icon": None, "hash": None}, + "set_mac_addr": set_mac, + "get_sysinfo": None, + }, + "emeter": { + "get_realtime": None, + "get_daystat": None, + "get_monthstat": None, + "erase_emeter_state": None, + }, + "smartlife.iot.common.emeter": { + "get_realtime": None, + "get_daystat": None, + "get_monthstat": None, + "erase_emeter_state": None, + }, + "smartlife.iot.smartbulb.lightingservice": { + "get_light_state": light_state, + "get_light_details": LIGHT_DETAILS, + "get_default_behavior": DEFAULT_BEHAVIOR, + "transition_light_state": transition_light_state, + "set_preferred_state": set_preferred_state, + }, + "smartlife.iot.lighting_effect": { + "set_lighting_effect": set_lighting_effect, + }, + # lightstrip follows the same payloads but uses different module & method + "smartlife.iot.lightStrip": { + "set_light_state": transition_light_state, + "get_light_state": light_state, + "get_light_details": LIGHT_DETAILS, + "get_default_behavior": DEFAULT_BEHAVIOR, + "set_preferred_state": set_preferred_state, + }, + "smartlife.iot.common.system": { + "set_dev_alias": set_alias, + }, + "time": {**TIME_MODULE, "set_time": set_time, "set_timezone": set_time}, + "smartlife.iot.common.timesetting": { + **TIME_MODULE, + "set_time": set_time, + "set_timezone": set_time, + }, + # HS220 brightness, different setter and getter + "smartlife.iot.dimmer": { + "set_brightness": set_hs220_brightness, + "set_dimmer_transition": set_hs220_dimmer_transition, + }, + "smartlife.iot.LAS": AMBIENT_MODULE, + "smartlife.iot.PIR": MOTION_MODULE, + "cnCloud": CLOUD_MODULE, + "smartlife.iot.common.cloud": CLOUD_MODULE, + "schedule": SCHEDULE_MODULE, + "smartlife.iot.common.schedule": SCHEDULE_MODULE, + } + + async def send(self, request, port=9999): + if not self.verbatim: + return await self._send(request, port) + + # Simply return whatever is in the fixture + response = {} + for target in request: + if target in self.proto: + response.update({target: self.proto[target]}) + else: + response.update({"err_msg": "module not support"}) + return copy.deepcopy(response) + + async def _send(self, request, port=9999): + proto = self.proto + # collect child ids from context + try: + child_ids = request["context"]["child_ids"] + request.pop("context", None) + except KeyError: + child_ids = [] + + def get_response_for_module(target): + if target not in proto: + return error(msg="target not found") + if "err_code" in proto[target] and proto[target]["err_code"] != 0: + return {target: proto[target]} + + def get_response_for_command(cmd): + if cmd not in proto[target]: + return error(msg=f"command {cmd} not found") + + params = request[target][cmd] + _LOGGER.debug( + "Going to execute %s.%s (params: %s).. ", target, cmd, params + ) + + if callable(proto[target][cmd]): + res = proto[target][cmd](self, params, child_ids) + _LOGGER.debug("[callable] %s.%s: %s", target, cmd, res) + return success(res) + elif isinstance(proto[target][cmd], dict): + res = proto[target][cmd] + _LOGGER.debug("[static] %s.%s: %s", target, cmd, res) + return success(res) + else: + raise NotImplementedError(f"target {target} cmd {cmd}") + + from collections import defaultdict + + cmd_responses = defaultdict(dict) + for cmd in request[target]: + cmd_responses[target][cmd] = get_response_for_command(cmd) + + return cmd_responses + + response = {} + for target in request: + response.update(get_response_for_module(target)) + + return copy.deepcopy(response) + + async def close(self) -> None: + pass + + async def reset(self) -> None: + pass diff --git a/tests/fakeprotocol_smart.py b/tests/fakeprotocol_smart.py new file mode 100644 index 000000000..257e07ea2 --- /dev/null +++ b/tests/fakeprotocol_smart.py @@ -0,0 +1,727 @@ +import copy +from json import loads as json_loads +from warnings import warn + +import pytest + +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 + + +class FakeSmartProtocol(SmartProtocol): + def __init__(self, info, fixture_name, *, is_child=False, verbatim=False): + super().__init__( + transport=FakeSmartTransport( + info, fixture_name, is_child=is_child, verbatim=verbatim + ), + ) + + async def query(self, request, retry_count: int = 3): + """Implement query here so can still patch SmartProtocol.query.""" + resp_dict = await self._query(request, retry_count) + return resp_dict + + +class FakeSmartTransport(BaseTransport): + def __init__( + self, + info, + fixture_name, + *, + list_return_size=10, + component_nego_not_included=False, + warn_fixture_missing_methods=True, + fix_incomplete_fixture_lists=True, + is_child=False, + get_child_fixtures=True, + verbatim=False, + ): + super().__init__( + config=DeviceConfig( + "127.0.0.123", + credentials=Credentials( + username="dummy_user", + password="dummy_password", # noqa: S106 + ), + ), + ) + 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.verbatim + ) + else: + self.info = info + if not component_nego_not_included: + self.components = { + comp["id"]: comp["ver_code"] + for comp in self.info["component_nego"]["component_list"] + } + self.list_return_size = list_return_size + self.warn_fixture_missing_methods = warn_fixture_missing_methods + self.fix_incomplete_fixture_lists = fix_incomplete_fixture_lists + + if verbatim: + self.warn_fixture_missing_methods = False + self.fix_incomplete_fixture_lists = False + + @property + def default_port(self): + """Default port for the transport.""" + return 80 + + @property + def credentials_hash(self): + """The hashed credentials used by the transport.""" + return self._credentials.username + self._credentials.password + "hash" + + FIXTURE_MISSING_MAP = { + "get_wireless_scan_info": ("wireless", {"ap_list": [], "wep_supported": False}), + "get_auto_off_config": ("auto_off", {"delay_min": 10, "enable": False}), + "get_led_info": ( + "led", + { + "led_rule": "never", + "led_status": False, + "night_mode": { + "end_time": 420, + "night_mode_type": "sunrise_sunset", + "start_time": 1140, + "sunrise_offset": 0, + "sunset_offset": 0, + }, + }, + ), + "get_latest_fw": ( + "firmware", + { + "fw_size": 0, + "fw_ver": "1.0.5 Build 230801 Rel.095702", + "hw_id": "", + "need_to_upgrade": False, + "oem_id": "", + "release_date": "", + "release_note": "", + "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", 2), + {"enable": True, "random_range": 120, "time": 180}, + ), + "get_alarm_configure": ( + "alarm", + { + "duration": 10, + "type": "Doorbell Ring 2", + "volume": "low", + }, + ), + "get_support_alarm_type_list": ( + "alarm", + { + "alarm_type_list": [ + "Doorbell Ring 1", + ] + }, + ), + "get_device_usage": ("device", {}), + "get_connect_cloud_state": ("cloud_connect", {"status": 0}), + "get_emeter_data": ( + "energy_monitoring", + { + "current_ma": 33, + "energy_wh": 971, + "power_mw": 1003, + "voltage_mv": 121215, + }, + ), + "get_emeter_vgain_igain": ( + "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"] + + if method == "multipleRequest": + params = request_dict["params"] + responses = [] + for request in params["requests"]: + response = await self._send_request(request) # type: ignore[arg-type] + # Devices do not continue after error + if response["error_code"] != 0: + break + response["method"] = request["method"] # type: ignore[index] + responses.append(response) + return {"result": {"responses": responses}, "error_code": 0} + else: + return await self._send_request(request_dict) + + @staticmethod + def _get_child_protocols( + parent_fixture_info, parent_fixture_name, child_devices_key, verbatim + ): + child_infos = parent_fixture_info.get(child_devices_key, {}).get( + "child_device_list", [] + ) + if not child_infos: + return + found_child_fixture_infos = [] + child_protocols = {} + # imported here to avoid circular import + from .conftest import filter_fixtures + + def try_get_child_fixture_info(child_dev_info, protocol): + hw_version = child_dev_info["hw_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.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={protocol}, + model_filter={child_fixture_name}, + ) + if child_fixtures: + return next(iter(child_fixtures)) + return None + + for child_info in child_infos: + if ( # Is SMART protocol + (device_id := child_info.get("device_id")) + 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, "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, + verbatim=verbatim, + ) + # Look for fixture inline + elif (child_fixtures := parent_fixture_info.get("child_devices")) and ( + child_fixture := child_fixtures.get(device_id) + ): + found_child_fixture_infos.append(child_fixture["get_device_info"]) + child_protocols[device_id] = FakeSmartProtocol( + 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 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 not verbatim and child_infos and found_child_fixture_infos: + parent_fixture_info[child_devices_key]["child_device_list"] = ( + found_child_fixture_infos + ) + return child_protocols + + async def _handle_control_child(self, params: dict): + """Handle control_child command.""" + device_id = params.get("device_id") + if device_id not in self.child_protocols: + # no need to warn as the warning was raised during protocol init + return self._handle_control_child_missing(params) + + child_protocol: SmartProtocol = self.child_protocols[device_id] + + request_data = params.get("requestData", {}) + + child_method = request_data.get("method") + child_params = request_data.get("params") # noqa: F841 + + resp = await child_protocol.query({child_method: child_params}) + resp["error_code"] = 0 + for val in resp.values(): + return { + "result": {"responseData": {"result": val, "error_code": 0}}, + "error_code": 0, + } + + def _handle_control_child_missing(self, params: dict): + """Handle control_child command. + + Used for older fixtures where child info wasn't stored in the fixture. + TODO: Should be removed somehow for future maintanability. + """ + device_id = params.get("device_id") + request_data = params.get("requestData", {}) + + child_method = request_data.get("method") + child_params = request_data.get("params") + + info = self.info + children = info["get_child_device_list"]["child_device_list"] + + for child in children: + if child["device_id"] == device_id: + info = child + break + # Create the child_devices fixture section for fixtures generated before it was added + if "child_devices" not in self.info: + self.info["child_devices"] = {} + # Get the method calls made directly on the child devices + child_device_calls = self.info["child_devices"].setdefault(device_id, {}) + + # We only support get & set device info in this method for missing. + if child_method == "get_device_info": + result = copy.deepcopy(info) + return {"result": result, "error_code": 0} + elif child_method == "set_device_info": + info.update(child_params) + return {"error_code": 0} + elif child_method == "set_preset_rules": + return self._set_child_preset_rules(info, child_params) + elif child_method == "set_on_off_gradually_info": + return self._set_on_off_gradually_info(info, child_params) + elif child_method in child_device_calls: + result = copy.deepcopy(child_device_calls[child_method]) + return {"result": result, "error_code": 0} + elif missing_result := self._missing_result(child_method): + # FIXTURE_MISSING is for service calls not in place when + # SMART fixtures started to be generated + # Copy to info so it will work with update methods + child_device_calls[child_method] = missing_result + result = copy.deepcopy(info[child_method]) + retval = {"result": result, "error_code": 0} + return retval + 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." + ) + child_device_calls[target_method].update(child_params) + return {"error_code": 0} + else: + # PARAMS error returned for KS240 when get_device_usage called + # on parent device. Could be any error code though. + # TODO: Try to figure out if there's a way to prevent the KS240 smartdevice + # calling the unsupported device in the first place. + retval = { + "error_code": SmartErrorCode.PARAMS_ERROR.value, + "method": child_method, + } + return retval + + raise NotImplementedError(f"Method {child_method} not implemented for children") + + def _get_on_off_gradually_info(self, info, params): + if self.components["on_off_gradually"] == 1: + info["get_on_off_gradually_info"] = {"enable": True} + else: + info["get_on_off_gradually_info"] = { + "off_state": {"duration": 5, "enable": False, "max_duration": 60}, + "on_state": {"duration": 5, "enable": False, "max_duration": 60}, + } + return copy.deepcopy(info["get_on_off_gradually_info"]) + + def _set_on_off_gradually_info(self, info, params): + # Child devices can have the required properties directly in info + + # the _handle_control_child_missing directly passes in get_device_info + sys_info = info.get("get_device_info", info) + + if self.components["on_off_gradually"] == 1: + info["get_on_off_gradually_info"] = {"enable": params["enable"]} + elif on_state := params.get("on_state"): + if "fade_on_time" in sys_info and "gradually_on_mode" in sys_info: + sys_info["gradually_on_mode"] = 1 if on_state["enable"] else 0 + if "duration" in on_state: + sys_info["fade_on_time"] = on_state["duration"] + if "get_on_off_gradually_info" in info: + info["get_on_off_gradually_info"]["on_state"]["enable"] = on_state[ + "enable" + ] + if "duration" in on_state: + info["get_on_off_gradually_info"]["on_state"]["duration"] = ( + on_state["duration"] + ) + elif off_state := params.get("off_state"): + if "fade_off_time" in sys_info and "gradually_off_mode" in sys_info: + sys_info["gradually_off_mode"] = 1 if off_state["enable"] else 0 + if "duration" in off_state: + sys_info["fade_off_time"] = off_state["duration"] + if "get_on_off_gradually_info" in info: + info["get_on_off_gradually_info"]["off_state"]["enable"] = off_state[ + "enable" + ] + if "duration" in off_state: + info["get_on_off_gradually_info"]["off_state"]["duration"] = ( + off_state["duration"] + ) + return {"error_code": 0} + + def _set_dynamic_light_effect(self, info, params): + """Set or remove values as per the device behaviour.""" + info["get_device_info"]["dynamic_light_effect_enable"] = params["enable"] + info["get_dynamic_light_effect_rules"]["enable"] = params["enable"] + if params["enable"]: + info["get_device_info"]["dynamic_light_effect_id"] = params["id"] + info["get_dynamic_light_effect_rules"]["current_rule_id"] = params["id"] + else: + if "dynamic_light_effect_id" in info["get_device_info"]: + del info["get_device_info"]["dynamic_light_effect_id"] + if "current_rule_id" in info["get_dynamic_light_effect_rules"]: + del info["get_dynamic_light_effect_rules"]["current_rule_id"] + + def _set_edit_dynamic_light_effect_rule(self, info, params): + """Edit dynamic light effect rule.""" + rules = info["get_dynamic_light_effect_rules"]["rule_list"] + for rule in rules: + if rule["id"] == params["id"]: + rule.update(params) + return + + raise Exception("Unable to find rule with id") + + def _set_light_strip_effect(self, info, params): + """Set or remove values as per the device behaviour.""" + # Brightness is not always available + if (brightness := params.get("brightness")) is not None: + info["get_device_info"]["lighting_effect"]["brightness"] = brightness + if "enable" in params: + info["get_device_info"]["lighting_effect"]["enable"] = params["enable"] + info["get_device_info"]["lighting_effect"]["name"] = params["name"] + info["get_device_info"]["lighting_effect"]["id"] = params["id"] + info["get_lighting_effect"] = copy.deepcopy(params) + + def _set_led_info(self, info, params): + """Set or remove values as per the device behaviour.""" + info["get_led_info"]["led_status"] = params["led_rule"] != "never" + info["get_led_info"]["led_rule"] = params["led_rule"] + + def _set_preset_rules(self, info, params): + """Set or remove values as per the device behaviour.""" + if "brightness" not in info["get_preset_rules"]: + return {"error_code": SmartErrorCode.PARAMS_ERROR} + info["get_preset_rules"]["brightness"] = params["brightness"] + # So far the only child device with light preset (KS240) also has the + # data available to read in the device_info. + device_info = info["get_device_info"] + if "preset_state" in device_info: + device_info["preset_state"] = [ + {"brightness": b} for b in params["brightness"] + ] + return {"error_code": 0} + + def _set_child_preset_rules(self, info, params): + """Set or remove values as per the device behaviour.""" + # So far the only child device with light preset (KS240) has the + # data available to read in the device_info. If a child device + # appears that doesn't have this this will need to be extended. + if "preset_state" not in info: + return {"error_code": SmartErrorCode.PARAMS_ERROR} + info["preset_state"] = [{"brightness": b} for b in params["brightness"]] + return {"error_code": 0} + + def _edit_preset_rules(self, info, params): + """Set or remove values as per the device behaviour.""" + if "states" not in info["get_preset_rules"] is None: + return {"error_code": SmartErrorCode.PARAMS_ERROR} + info["get_preset_rules"]["states"][params["index"]] = params["state"] + return {"error_code": 0} + + def _set_temperature_unit(self, info, params): + """Set or remove values as per the device behaviour.""" + unit = params["temp_unit"] + if unit not in {"celsius", "fahrenheit"}: + raise ValueError(f"Invalid value for temperature unit {unit}") + if "temp_unit" not in info["get_device_info"]: + return {"error_code": SmartErrorCode.UNKNOWN_METHOD_ERROR} + else: + info["get_device_info"]["temp_unit"] = unit + return {"error_code": 0} + + def _update_sysinfo_key(self, info: dict, key: str, value: str) -> dict: + """Update a single key in the main system info. + + This is used to implement child device setters that change the main sysinfo state. + """ + sys_info = info.get("get_device_info", info) + sys_info[key] = value + + 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"] + + info = self.info + if method == "control_child": + return await self._handle_control_child(request_dict["params"]) + + params = request_dict.get("params", {}) + 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: + return self._get_method_from_info(method, params) + + if self.verbatim: + return { + "error_code": SmartErrorCode.PARAMS_ERROR.value, + "method": method, + } + + if missing_result := self._missing_result(method): + # FIXTURE_MISSING is for service calls not in place when + # SMART fixtures started to be generated + # Copy to info so it will work with update methods + info[method] = missing_result + result = copy.deepcopy(info[method]) + retval = {"result": result, "error_code": 0} + elif ( + method == "get_on_off_gradually_info" + and "on_off_gradually" in self.components + ): + # Need to call a method here to determine which version schema to return + result = self._get_on_off_gradually_info(info, params) + return {"result": result, "error_code": 0} + else: + # PARAMS error returned for KS240 when get_device_usage called + # on parent device. Could be any error code though. + # TODO: Try to figure out if there's a way to prevent the KS240 smartdevice + # calling the unsupported device in the first place. + retval = { + "error_code": SmartErrorCode.PARAMS_ERROR.value, + "method": method, + } + # Reduce warning spam by consolidating and reporting at the end of the run + if self.warn_fixture_missing_methods: + pytest.fixtures_missing_methods.setdefault( # type: ignore[attr-defined] + self.fixture_name, set() + ).add(method) + return retval + 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) + return {"error_code": 0} + elif method == "edit_dynamic_light_effect_rule": + self._set_edit_dynamic_light_effect_rule(info, params) + return {"error_code": 0} + elif method == "set_lighting_effect": + self._set_light_strip_effect(info, params) + return {"error_code": 0} + elif method == "set_led_info": + self._set_led_info(info, params) + return {"error_code": 0} + elif method == "set_preset_rules": + return self._set_preset_rules(info, params) + elif method == "edit_preset_rules": + return self._edit_preset_rules(info, params) + elif method == "set_temperature_unit": + return self._set_temperature_unit(info, params) + elif method == "set_on_off_gradually_info": + 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 == "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: + pass + + async def reset(self) -> None: + pass diff --git a/tests/fakeprotocol_smartcam.py b/tests/fakeprotocol_smartcam.py new file mode 100644 index 000000000..171602330 --- /dev/null +++ b/tests/fakeprotocol_smartcam.py @@ -0,0 +1,414 @@ +from __future__ import annotations + +import copy +from json import loads as json_loads +from typing import Any + +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 + + +class FakeSmartCamProtocol(SmartCamProtocol): + def __init__(self, info, fixture_name, *, is_child=False, verbatim=False): + super().__init__( + transport=FakeSmartCamTransport( + info, fixture_name, is_child=is_child, verbatim=verbatim + ), + ) + + async def query(self, request, retry_count: int = 3): + """Implement query here so can still patch SmartProtocol.query.""" + resp_dict = await self._query(request, retry_count) + return resp_dict + + +class FakeSmartCamTransport(BaseTransport): + def __init__( + self, + info, + fixture_name, + *, + list_return_size=10, + is_child=False, + get_child_fixtures=True, + verbatim=False, + components_not_included=False, + ): + super().__init__( + config=DeviceConfig( + "127.0.0.123", + credentials=Credentials( + username="dummy_user", + password="dummy_password", # noqa: S106 + ), + ), + ) + + 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) + # 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.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.""" + return 443 + + @property + def credentials_hash(self): + """The hashed credentials used by the transport.""" + return self._credentials.username + self._credentials.password + "camerahash" + + async def send(self, request: str): + request_dict = json_loads(request) + method = request_dict["method"] + + if method == "multipleRequest": + params = request_dict["params"] + responses = [] + for request in params["requests"]: + response = await self._send_request(request) # type: ignore[arg-type] + response["method"] = request["method"] # type: ignore[index] + responses.append(response) + # Devices do not continue after error + if response["error_code"] != 0: + break + return {"result": {"responses": responses}, "error_code": 0} + else: + return await self._send_request(request_dict) + + async def _handle_control_child(self, params: dict): + """Handle control_child command.""" + device_id = params.get("device_id") + assert device_id in self.child_protocols, "Fixture does not have child info" + + child_protocol: SmartProtocol = self.child_protocols[device_id] + + request_data = params.get("request_data", {}) + + child_method = request_data.get("method") + child_params = request_data.get("params") # noqa: F841 + + resp = await child_protocol.query({child_method: child_params}) + resp["error_code"] = 0 + for val in resp.values(): + return { + "result": {"response_data": {"result": val, "error_code": 0}}, + "error_code": 0, + } + + @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"): [ + "getDeviceInfo", + "device_info", + "basic_info", + "device_alias", + ], + # setTimezone maps to getClockStatus + ("system", "clock_status", "seconds_from_1970"): [ + "getClockStatus", + "system", + "clock_status", + "seconds_from_1970", + ], + # setTimezone maps to getClockStatus + ("system", "clock_status", "local_time"): [ + "getClockStatus", + "system", + "clock_status", + "local_time", + ], + } + + 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" + ) + 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"] + ) + + if method[:3] == "set": + get_method = "g" + method[1:] + for key, val in request_dict.items(): + if key == "method": + continue + # key is params for multi request and the actual params + # for single requests + if key == "params": + module = next(iter(val)) + val = val[module] + else: + module = key + section = next(iter(val)) + skey_val = val[section] + if not isinstance(skey_val, dict): # single level query + 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 + section_value = sval + 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, {}) + .get(module, {}) + .get(section, {}) + ) and section_key in section: + section[section_key] = section_value + else: + return {"error_code": -1} + break + return {"error_code": 0} + elif method == "get": + module = self._get_second_key(request_dict) + get_method = f"get_{module}" + if get_method in info: + result = copy.deepcopy(info[get_method]["get"]) + return {**result, "error_code": 0} + else: + return {"error_code": -1} + 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") + 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} + + return {"error_code": -1} + + async def close(self) -> None: + pass + + async def reset(self) -> None: + pass diff --git a/tests/fixtureinfo.py b/tests/fixtureinfo.py new file mode 100644 index 000000000..fbfe6ff80 --- /dev/null +++ b/tests/fixtureinfo.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +import copy +import glob +import json +import os +from collections.abc import Iterable +from pathlib import Path +from typing import NamedTuple + +import pytest + +from kasa.device_type import DeviceType +from kasa.iot import IotDevice +from kasa.smart.smartdevice import SmartDevice +from kasa.smartcam import SmartCamDevice + + +class FixtureInfo(NamedTuple): + name: str + protocol: str + data: dict + + +class ComponentFilter(NamedTuple): + component_name: str + minimum_version: int = 0 + maximum_version: int | None = None + + +FixtureInfo.__hash__ = lambda self: hash((self.name, self.protocol)) # type: ignore[attr-defined, method-assign] +FixtureInfo.__eq__ = lambda x, y: hash(x) == hash(y) # type: ignore[method-assign] + + +SUPPORTED_IOT_DEVICES = [ + (device, "IOT") + for device in glob.glob( + os.path.dirname(os.path.abspath(__file__)) + "/fixtures/iot/*.json" + ) +] + +SUPPORTED_SMART_DEVICES = [ + (device, "SMART") + for device in glob.glob( + os.path.dirname(os.path.abspath(__file__)) + "/fixtures/smart/*.json" + ) +] + +SUPPORTED_SMART_CHILD_DEVICES = [ + (device, "SMART.CHILD") + for device in glob.glob( + os.path.dirname(os.path.abspath(__file__)) + "/fixtures/smart/child/*.json" + ) +] + +SUPPORTED_SMARTCAM_DEVICES = [ + (device, "SMARTCAM") + for device in glob.glob( + os.path.dirname(os.path.abspath(__file__)) + "/fixtures/smartcam/*.json" + ) +] + +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 +) + + +def idgenerator(paramtuple: FixtureInfo): + try: + return paramtuple.name + ( + "" if paramtuple.protocol == "IOT" else "-" + paramtuple.protocol + ) + except: # TODO: HACK as idgenerator is now used by default # noqa: E722 + return None + + +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) + + with open(file) as f: + data = json.load(f) + + fixture_name = p.name + fixture_data.append( + FixtureInfo(data=data, protocol=protocol, name=fixture_name) + ) + return fixture_data + + +FIXTURE_DATA: list[FixtureInfo] = get_fixture_infos() + + +def filter_fixtures( + desc, + *, + data_root_filter: str | None = None, + protocol_filter: set[str] | None = None, + model_filter: set[str] | None = None, + model_startswith_filter: str | None = None, + component_filter: str | ComponentFilter | None = None, + device_type_filter: Iterable[DeviceType] | None = None, + fixture_list: list[FixtureInfo] = FIXTURE_DATA, +): + """Filter the fixtures based on supplied parameters. + + data_root_filter: return fixtures containing the supplied top + level key, i.e. discovery_result + protocol_filter: set of protocols to match, IOT, SMART, SMART.CHILD + model_filter: set of device models to match + component_filter: filter SMART fixtures that have the provided + component in component_nego details. + """ + + def _model_match(fixture_data: FixtureInfo, model_filter: set[str]): + if isinstance(model_filter, str): + model_filter = {model_filter} + assert isinstance(model_filter, set), "model filter must be a set" + model_filter_list = [mf for mf in model_filter] + if ( + len(model_filter_list) == 1 + and (model := model_filter_list[0]) + and len(model.split("_")) == 3 + ): + # filter string includes hw and fw, return exact match + return fixture_data.name == f"{model}.json" + file_model_region = fixture_data.name.split("_")[0] + file_model = file_model_region.split("(")[0] + return file_model in model_filter + + def _model_startswith_match(fixture_data: FixtureInfo, starts_with: str): + return fixture_data.name.startswith(starts_with) + + def _component_match( + fixture_data: FixtureInfo, component_filter: str | ComponentFilter + ): + 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 + if isinstance(component_filter, str): + return component_filter in components + else: + return ( + (ver_code := components.get(component_filter.component_name)) + and ver_code >= component_filter.minimum_version + and ( + component_filter.maximum_version is None + or ver_code <= component_filter.maximum_version + ) + ) + + def _device_type_match(fixture_data: FixtureInfo, device_type): + if fixture_data.protocol in {"SMART", "SMART.CHILD"}: + info = fixture_data.data["get_device_info"] + component_nego = fixture_data.data["component_nego"] + components = [ + component["id"] for component in component_nego["component_list"] + ] + return ( + SmartDevice._get_device_type_from_components(components, info["type"]) + in device_type + ) + elif fixture_data.protocol == "IOT": + return ( + IotDevice._get_device_type_from_sys_info(fixture_data.data) + in device_type + ) + 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 + + filtered = [] + if protocol_filter is None: + protocol_filter = {"IOT", "SMART", "SMARTCAM"} + for fixture_data in fixture_list: + if data_root_filter and data_root_filter not in fixture_data.data: + continue + if fixture_data.protocol not in protocol_filter: + continue + if model_filter is not None and not _model_match(fixture_data, model_filter): + continue + if model_startswith_filter is not None and not _model_startswith_match( + fixture_data, model_startswith_filter + ): + continue + if component_filter and not _component_match(fixture_data, component_filter): + continue + if device_type_filter and not _device_type_match( + fixture_data, device_type_filter + ): + continue + + filtered.append(fixture_data) + + filtered.sort() + return filtered + + +@pytest.fixture( + params=filter_fixtures("all fixture infos"), + ids=idgenerator, +) +def fixture_info(request, mocker): + """Return raw discovery file contents as JSON. Used for discovery tests.""" + fixture_info = request.param + fixture_data = copy.deepcopy(fixture_info.data) + return FixtureInfo(fixture_info.name, fixture_info.protocol, fixture_data) 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 new file mode 100644 index 000000000..11cafb870 --- /dev/null +++ b/tests/fixtures/iot/EP10(US)_1.0_1.0.2.json @@ -0,0 +1,32 @@ +{ + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "dev_name": "Smart Wi-Fi Plug Mini", + "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": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "EP10(US)", + "next_action": { + "type": -1 + }, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "relay_state": 0, + "rssi": -64, + "status": "new", + "sw_ver": "1.0.2 Build 200915 Rel.085940", + "updating": 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 new file mode 100644 index 000000000..5be97e874 --- /dev/null +++ b/tests/fixtures/iot/EP40(US)_1.0_1.0.2.json @@ -0,0 +1,45 @@ +{ + "system": { + "get_sysinfo": { + "alias": "#MASKED_NAME#", + "child_num": 2, + "children": [ + { + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", + "next_action": { + "type": -1 + }, + "on_time": 0, + "state": 0 + }, + { + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", + "next_action": { + "type": -1 + }, + "on_time": 0, + "state": 0 + } + ], + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "EP40(US)", + "ntc_state": 0, + "oemId": "00000000000000000000000000000000", + "rssi": -47, + "status": "new", + "sw_ver": "1.0.2 Build 210105 Rel.165938", + "updating": 0 + } + } +} 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 new file mode 100644 index 000000000..6d15034f1 --- /dev/null +++ b/tests/fixtures/iot/ES20M(US)_1.0_1.0.11.json @@ -0,0 +1,184 @@ +{ + "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.LAS": { + "get_adc_value": { + "err_code": 0, + "type": 2, + "value": 0 + }, + "get_config": { + "devs": [ + { + "dark_index": 0, + "enable": 1, + "hw_id": 0, + "level_array": [ + { + "adc": 390, + "name": "cloudy", + "value": 15 + }, + { + "adc": 300, + "name": "overcast", + "value": 11 + }, + { + "adc": 222, + "name": "dawn", + "value": 8 + }, + { + "adc": 222, + "name": "twilight", + "value": 8 + }, + { + "adc": 111, + "name": "total darkness", + "value": 4 + }, + { + "adc": 2400, + "name": "custom", + "value": 94 + } + ], + "max_adc": 2550, + "min_adc": 0 + } + ], + "err_code": 0, + "ver": "1.0" + }, + "get_current_brt": { + "err_code": 0, + "value": 0 + }, + "get_dark_status": { + "bDark": 1, + "err_code": 0 + } + }, + "smartlife.iot.PIR": { + "get_adc_value": { + "err_code": 0, + "value": 2107 + }, + "get_config": { + "array": [ + 80, + 50, + 20, + 0 + ], + "cold_time": 120000, + "enable": 0, + "err_code": 0, + "max_adc": 4095, + "min_adc": 0, + "trigger_index": 1, + "version": "1.0" + } + }, + "smartlife.iot.dimmer": { + "get_default_behavior": { + "double_click": { + "mode": "unknown" + }, + "err_code": 0, + "long_press": { + "mode": "unknown" + } + }, + "get_dimmer_parameters": { + "bulb_type": 1, + "err_code": 0, + "fadeOffTime": 0, + "fadeOnTime": 0, + "gentleOffTime": 10000, + "gentleOnTime": 3000, + "minThreshold": 5, + "rampRate": 30 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "brightness": 100, + "dev_name": "Wi-Fi Smart Dimmer with sensor", + "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": "28:87:BA:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "ES20M(US)", + "next_action": { + "type": -1 + }, + "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": -57, + "status": "new", + "sw_ver": "1.0.11 Build 240514 Rel.110351", + "updating": 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 new file mode 100644 index 000000000..e28301d5a --- /dev/null +++ b/tests/fixtures/iot/ES20M(US)_1.0_1.0.8.json @@ -0,0 +1,126 @@ +{ + "smartlife.iot.LAS": { + "get_config": { + "devs": [ + { + "dark_index": 0, + "enable": 1, + "hw_id": 0, + "level_array": [ + { + "adc": 367, + "name": "cloudy", + "value": 14 + }, + { + "adc": 300, + "name": "overcast", + "value": 12 + }, + { + "adc": 222, + "name": "dawn", + "value": 9 + }, + { + "adc": 222, + "name": "twilight", + "value": 9 + }, + { + "adc": 111, + "name": "total darkness", + "value": 4 + }, + { + "adc": 2400, + "name": "custom", + "value": 97 + } + ], + "max_adc": 2450, + "min_adc": 0 + } + ], + "err_code": 0, + "ver": "1.0" + } + }, + "smartlife.iot.PIR": { + "get_config": { + "array": [ + 80, + 50, + 20, + 0 + ], + "cold_time": 600000, + "enable": 1, + "err_code": 0, + "max_adc": 4095, + "min_adc": 0, + "trigger_index": 0, + "version": "1.0" + } + }, + "smartlife.iot.dimmer": { + "get_dimmer_parameters": { + "bulb_type": 1, + "err_code": 0, + "fadeOffTime": 2000, + "fadeOnTime": 1000, + "gentleOffTime": 10000, + "gentleOnTime": 3000, + "minThreshold": 14, + "rampRate": 30 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "brightness": 35, + "dev_name": "Wi-Fi Smart Dimmer with sensor", + "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": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "ES20M(US)", + "next_action": { + "type": -1 + }, + "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": -59, + "status": "new", + "sw_ver": "1.0.8 Build 211201 Rel.123822", + "updating": 0 + } + } +} diff --git a/tests/fixtures/iot/HS100(UK)_1.0_1.2.6.json b/tests/fixtures/iot/HS100(UK)_1.0_1.2.6.json new file mode 100644 index 000000000..fd98bd083 --- /dev/null +++ b/tests/fixtures/iot/HS100(UK)_1.0_1.2.6.json @@ -0,0 +1,28 @@ +{ + "system": { + "get_sysinfo": { + "active_mode": "schedule", + "alias": "#MASKED_NAME#", + "dev_name": "Wi-Fi Smart Plug", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "fwId": "00000000000000000000000000000000", + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "icon_hash": "", + "latitude": 0, + "led_off": 0, + "longitude": 0, + "mac": "70:4F:57:00:00:00", + "model": "HS100(UK)", + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "relay_state": 0, + "rssi": -59, + "sw_ver": "1.2.6 Build 200727 Rel.120236", + "type": "IOT.SMARTPLUGSWITCH", + "updating": 0 + } + } +} 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 new file mode 100644 index 000000000..324e193a7 --- /dev/null +++ b/tests/fixtures/iot/HS100(UK)_4.1_1.1.0.json @@ -0,0 +1,50 @@ +{ + "discovery_result": { + "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": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "dev_name": "Smart Wi-Fi Plug", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "4.1", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "CC:32:E5:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "HS100(UK)", + "next_action": { + "type": -1 + }, + "ntc_state": 0, + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "relay_state": 0, + "rssi": -43, + "status": "new", + "sw_ver": "1.1.0 Build 201016 Rel.175121", + "updating": 0 + } + } +} diff --git a/kasa/tests/fixtures/HS100(US)_1.0_real.json b/tests/fixtures/iot/HS100(US)_1.0_1.2.5.json similarity index 97% rename from kasa/tests/fixtures/HS100(US)_1.0_real.json rename to tests/fixtures/iot/HS100(US)_1.0_1.2.5.json index 1bbe29d4c..1f2cad626 100644 --- a/kasa/tests/fixtures/HS100(US)_1.0_real.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/kasa/tests/fixtures/HS100(US)_2.0_real.json b/tests/fixtures/iot/HS100(US)_2.0_1.5.6.json similarity index 97% rename from kasa/tests/fixtures/HS100(US)_2.0_real.json rename to tests/fixtures/iot/HS100(US)_2.0_1.5.6.json index 03dd42d57..f73d62331 100644 --- a/kasa/tests/fixtures/HS100(US)_2.0_real.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/kasa/tests/fixtures/HS103(US)_1.0.json b/tests/fixtures/iot/HS103(US)_1.0_1.5.7.json similarity index 97% rename from kasa/tests/fixtures/HS103(US)_1.0.json rename to tests/fixtures/iot/HS103(US)_1.0_1.5.7.json index e5928c3dc..ec388dd33 100644 --- a/kasa/tests/fixtures/HS103(US)_1.0.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/kasa/tests/fixtures/HS103(US)_2.1.json b/tests/fixtures/iot/HS103(US)_2.1_1.1.2.json similarity index 97% rename from kasa/tests/fixtures/HS103(US)_2.1.json rename to tests/fixtures/iot/HS103(US)_2.1_1.1.2.json index 664845f6a..a9064ac74 100644 --- a/kasa/tests/fixtures/HS103(US)_2.1.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 new file mode 100644 index 000000000..cf7cb9355 --- /dev/null +++ b/tests/fixtures/iot/HS103(US)_2.1_1.1.4.json @@ -0,0 +1,31 @@ +{ + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "dev_name": "Smart Wi-Fi Plug Lite", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "2.1", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "HS103(US)", + "next_action": { + "type": -1 + }, + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "relay_state": 0, + "rssi": -48, + "status": "new", + "sw_ver": "1.1.4 Build 210409 Rel.113427", + "updating": 0 + } + } +} diff --git a/kasa/tests/fixtures/HS105(US)_1.0_real.json b/tests/fixtures/iot/HS105(US)_1.0_1.5.6.json similarity index 97% rename from kasa/tests/fixtures/HS105(US)_1.0_real.json rename to tests/fixtures/iot/HS105(US)_1.0_1.5.6.json index 796910043..a84c0f49b 100644 --- a/kasa/tests/fixtures/HS105(US)_1.0_real.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/kasa/tests/fixtures/HS107(US)_1.0_real.json b/tests/fixtures/iot/HS107(US)_1.0_1.0.8.json similarity index 84% rename from kasa/tests/fixtures/HS107(US)_1.0_real.json rename to tests/fixtures/iot/HS107(US)_1.0_1.0.8.json index 046a89e97..ddc61ef80 100644 --- a/kasa/tests/fixtures/HS107(US)_1.0_real.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 new file mode 100644 index 000000000..e75b18bc5 --- /dev/null +++ b/tests/fixtures/iot/HS110(EU)_1.0_1.2.5.json @@ -0,0 +1,37 @@ +{ + "emeter": { + "get_realtime": { + "current": 0.014937, + "err_code": 0, + "power": 0.928511, + "total": 55.139, + "voltage": 231.067823 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "schedule", + "alias": "#MASKED_NAME#", + "dev_name": "Wi-Fi Smart Plug With Energy Monitoring", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM:ENE", + "fwId": "00000000000000000000000000000000", + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "icon_hash": "", + "latitude": 0, + "led_off": 1, + "longitude": 0, + "mac": "50:C7:BF:00:00:00", + "model": "HS110(EU)", + "oemId": "00000000000000000000000000000000", + "on_time": 6023162, + "relay_state": 1, + "rssi": -71, + "sw_ver": "1.2.5 Build 171213 Rel.101523", + "type": "IOT.SMARTPLUGSWITCH", + "updating": 0 + } + } +} diff --git a/tests/fixtures/iot/HS110(EU)_4.0_1.0.4.json b/tests/fixtures/iot/HS110(EU)_4.0_1.0.4.json new file mode 100644 index 000000000..1d9b3d3ed --- /dev/null +++ b/tests/fixtures/iot/HS110(EU)_4.0_1.0.4.json @@ -0,0 +1,40 @@ +{ + "emeter": { + "get_realtime": { + "current_ma": 451, + "err_code": 0, + "power_mw": 61753, + "total_wh": 16323, + "voltage_mv": 230837 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "dev_name": "Smart Wi-Fi Plug With Energy Monitoring", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM:ENE", + "hwId": "00000000000000000000000000000000", + "hw_ver": "4.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 1, + "longitude_i": 0, + "mac": "B0:95:75:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "HS110(EU)", + "next_action": { + "type": -1 + }, + "oemId": "00000000000000000000000000000000", + "on_time": 1484778, + "relay_state": 1, + "rssi": -60, + "status": "new", + "sw_ver": "1.0.4 Build 191111 Rel.143500", + "updating": 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 new file mode 100644 index 000000000..cf5ac0654 --- /dev/null +++ b/tests/fixtures/iot/HS110(US)_1.0_1.2.6.json @@ -0,0 +1,37 @@ +{ + "emeter": { + "get_realtime": { + "current": 0.128037, + "err_code": 0, + "power": 7.677094, + "total": 30.404, + "voltage": 118.917389 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "schedule", + "alias": "#MASKED_NAME#", + "dev_name": "Wi-Fi Smart Plug With Energy Monitoring", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM:ENE", + "fwId": "00000000000000000000000000000000", + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "icon_hash": "", + "latitude": 0, + "led_off": 0, + "longitude": 0, + "mac": "00:00:00:00:00:00", + "model": "HS110(US)", + "oemId": "00000000000000000000000000000000", + "on_time": 14048150, + "relay_state": 1, + "rssi": -38, + "sw_ver": "1.2.6 Build 200727 Rel.121701", + "type": "IOT.SMARTPLUGSWITCH", + "updating": 0 + } + } +} diff --git a/kasa/tests/fixtures/HS200(US)_2.0_real.json b/tests/fixtures/iot/HS200(US)_2.0_1.5.7.json similarity index 97% rename from kasa/tests/fixtures/HS200(US)_2.0_real.json rename to tests/fixtures/iot/HS200(US)_2.0_1.5.7.json index 2fbcc65cb..31e4a5f90 100644 --- a/kasa/tests/fixtures/HS200(US)_2.0_real.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)_3.0_1.1.5.json b/tests/fixtures/iot/HS200(US)_3.0_1.1.5.json new file mode 100644 index 000000000..f953e7a12 --- /dev/null +++ b/tests/fixtures/iot/HS200(US)_3.0_1.1.5.json @@ -0,0 +1,31 @@ +{ + "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": "3.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "D8:07:B6:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "HS200(US)", + "next_action": { + "type": -1 + }, + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "relay_state": 0, + "rssi": -44, + "status": "new", + "sw_ver": "1.1.5 Build 210422 Rel.082129", + "updating": 0 + } + } +} diff --git a/tests/fixtures/iot/HS200(US)_5.0_1.0.11.json b/tests/fixtures/iot/HS200(US)_5.0_1.0.11.json new file mode 100644 index 000000000..19780635d --- /dev/null +++ b/tests/fixtures/iot/HS200(US)_5.0_1.0.11.json @@ -0,0 +1,32 @@ +{ + "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": "5.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "28:87:BA:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "HS200(US)", + "next_action": { + "type": -1 + }, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "relay_state": 0, + "rssi": -41, + "status": "new", + "sw_ver": "1.0.11 Build 230908 Rel.160526", + "updating": 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 new file mode 100644 index 000000000..44370f2ed --- /dev/null +++ b/tests/fixtures/iot/HS200(US)_5.0_1.0.2.json @@ -0,0 +1,31 @@ +{ + "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": "5.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "HS200(US)", + "next_action": { + "type": -1 + }, + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "relay_state": 0, + "rssi": -50, + "status": "new", + "sw_ver": "1.0.2 Build 200819 Rel.105309", + "updating": 0 + } + } +} diff --git a/kasa/tests/fixtures/HS210(US)_1.0_real.json b/tests/fixtures/iot/HS210(US)_1.0_1.5.8.json similarity index 97% rename from kasa/tests/fixtures/HS210(US)_1.0_real.json rename to tests/fixtures/iot/HS210(US)_1.0_1.5.8.json index ced3e8914..b286c53f2 100644 --- a/kasa/tests/fixtures/HS210(US)_1.0_real.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)_2.0_1.1.5.json b/tests/fixtures/iot/HS210(US)_2.0_1.1.5.json new file mode 100644 index 000000000..3478b2b51 --- /dev/null +++ b/tests/fixtures/iot/HS210(US)_2.0_1.1.5.json @@ -0,0 +1,31 @@ +{ + "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": "2.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "D8:07:B6:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "HS210(US)", + "next_action": { + "type": -1 + }, + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "relay_state": 0, + "rssi": -43, + "status": "new", + "sw_ver": "1.1.5 Build 210422 Rel.113212", + "updating": 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/kasa/tests/fixtures/HS220(US)_1.0_real.json b/tests/fixtures/iot/HS220(US)_1.0_1.5.7.json similarity index 94% rename from kasa/tests/fixtures/HS220(US)_1.0_real.json rename to tests/fixtures/iot/HS220(US)_1.0_1.5.7.json index 7c1662207..3826d198d 100644 --- a/kasa/tests/fixtures/HS220(US)_1.0_real.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 left dimmer", + "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/kasa/tests/fixtures/HS220(US)_1.0.json b/tests/fixtures/iot/HS220(US)_2.0_1.0.3.json similarity index 51% rename from kasa/tests/fixtures/HS220(US)_1.0.json rename to tests/fixtures/iot/HS220(US)_2.0_1.0.3.json index 15afc6134..d7d0a5a24 100644 --- a/kasa/tests/fixtures/HS220(US)_1.0.json +++ b/tests/fixtures/iot/HS220(US)_2.0_1.0.3.json @@ -1,21 +1,10 @@ { - "emeter": { - "get_realtime": { - "err_code": -1, - "err_msg": "module not support" - } - }, - "smartlife.iot.common.emeter": { - "err_code": -1, - "err_msg": "module not support" - }, "smartlife.iot.dimmer": { "get_dimmer_parameters": { - "bulb_type": 1, "err_code": 0, - "fadeOffTime": 3000, - "fadeOnTime": 3000, - "gentleOffTime": 510000, + "fadeOffTime": 1000, + "fadeOnTime": 1000, + "gentleOffTime": 10000, "gentleOnTime": 3000, "minThreshold": 0, "rampRate": 30 @@ -27,25 +16,26 @@ }, "system": { "get_sysinfo": { - "active_mode": "count_down", - "alias": "Mock hs220", - "brightness": 50, - "dev_name": "Smart Wi-Fi Dimmer", - "deviceId": "98E16F2D5ED204F3094CF472260237133DC0D547", + "active_mode": "none", + "alias": "#MASKED_NAME#", + "brightness": 100, + "dev_name": "Wi-Fi Smart Dimmer", + "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, "feature": "TIM", - "hwId": "231004CCCDB6C0B8FC7A3260C3470257", - "hw_ver": "1.0", + "hwId": "00000000000000000000000000000000", + "hw_ver": "2.0", "icon_hash": "", - "INVALIDlatitude": 11.6210, - "latitude_i": 11.6210, + "latitude_i": 0, "led_off": 0, - "INVALIDlongitude": 42.2074, - "longitude_i": 42.2074, - "mac": "50:c7:bf:af:75:5d", + "longitude_i": 0, + "mac": "00:00:00:00:00:00", "mic_type": "IOT.SMARTPLUGSWITCH", "model": "HS220(US)", - "oemId": "8FBD0F3CCF7E82836DC7996C524EF772", + "next_action": { + "type": -1 + }, + "oemId": "00000000000000000000000000000000", "on_time": 0, "preferred_state": [ { @@ -66,9 +56,8 @@ } ], "relay_state": 0, - "rssi": -65, - "sw_ver": "1.5.7 Build 180912 Rel.104837", - "type": "IOT.SMARTPLUGSWITCH", + "rssi": -45, + "sw_ver": "1.0.3 Build 200326 Rel.082355", "updating": 0 } } diff --git a/kasa/tests/fixtures/HS300(US)_1.0_real.json b/tests/fixtures/iot/HS300(US)_1.0_1.0.10.json similarity index 77% rename from kasa/tests/fixtures/HS300(US)_1.0_real.json rename to tests/fixtures/iot/HS300(US)_1.0_1.0.10.json index a6d34957d..0fc22a399 100644 --- a/kasa/tests/fixtures/HS300(US)_1.0_real.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 new file mode 100644 index 000000000..a174027ca --- /dev/null +++ b/tests/fixtures/iot/HS300(US)_1.0_1.0.21.json @@ -0,0 +1,89 @@ +{ + "emeter": { + "get_realtime": { + "current_ma": 544, + "err_code": 0, + "power_mw": 62430, + "total_wh": 26889, + "voltage_mv": 118389 + } + }, + "system": { + "get_sysinfo": { + "alias": "#MASKED_NAME#", + "child_num": 6, + "children": [ + { + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", + "next_action": { + "type": -1 + }, + "on_time": 1449897, + "state": 1 + }, + { + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", + "next_action": { + "type": -1 + }, + "on_time": 1449897, + "state": 1 + }, + { + "alias": "#MASKED_NAME# 3", + "id": "SCRUBBED_CHILD_DEVICE_ID_3", + "next_action": { + "type": -1 + }, + "on_time": 1449897, + "state": 1 + }, + { + "alias": "#MASKED_NAME# 4", + "id": "SCRUBBED_CHILD_DEVICE_ID_4", + "next_action": { + "type": -1 + }, + "on_time": 1449897, + "state": 1 + }, + { + "alias": "#MASKED_NAME# 5", + "id": "SCRUBBED_CHILD_DEVICE_ID_5", + "next_action": { + "type": -1 + }, + "on_time": 1449897, + "state": 1 + }, + { + "alias": "#MASKED_NAME# 6", + "id": "SCRUBBED_CHILD_DEVICE_ID_6", + "next_action": { + "type": -1 + }, + "on_time": 1449897, + "state": 1 + } + ], + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM:ENE", + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "HS300(US)", + "oemId": "00000000000000000000000000000000", + "rssi": -39, + "status": "new", + "sw_ver": "1.0.21 Build 210524 Rel.161309", + "updating": 0 + } + } +} 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 new file mode 100644 index 000000000..bca720892 --- /dev/null +++ b/tests/fixtures/iot/HS300(US)_2.0_1.0.12.json @@ -0,0 +1,90 @@ +{ + "emeter": { + "get_realtime": { + "current_ma": 6, + "err_code": 0, + "power_mw": 277, + "slot_id": 0, + "total_wh": 62, + "voltage_mv": 120110 + } + }, + "system": { + "get_sysinfo": { + "alias": "#MASKED_NAME#", + "child_num": 6, + "children": [ + { + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", + "next_action": { + "type": -1 + }, + "on_time": 710216, + "state": 1 + }, + { + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", + "next_action": { + "type": -1 + }, + "on_time": 710216, + "state": 1 + }, + { + "alias": "#MASKED_NAME# 3", + "id": "SCRUBBED_CHILD_DEVICE_ID_3", + "next_action": { + "type": -1 + }, + "on_time": 710216, + "state": 1 + }, + { + "alias": "#MASKED_NAME# 4", + "id": "SCRUBBED_CHILD_DEVICE_ID_4", + "next_action": { + "type": -1 + }, + "on_time": 710216, + "state": 1 + }, + { + "alias": "#MASKED_NAME# 5", + "id": "SCRUBBED_CHILD_DEVICE_ID_5", + "next_action": { + "type": -1 + }, + "on_time": 710216, + "state": 1 + }, + { + "alias": "#MASKED_NAME# 6", + "id": "SCRUBBED_CHILD_DEVICE_ID_6", + "next_action": { + "type": -1 + }, + "on_time": 710216, + "state": 1 + } + ], + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM:ENE", + "hwId": "00000000000000000000000000000000", + "hw_ver": "2.0", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "C0:06:C3:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "HS300(US)", + "oemId": "00000000000000000000000000000000", + "rssi": -44, + "status": "new", + "sw_ver": "1.0.12 Build 220121 Rel.175814", + "updating": 0 + } + } +} 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 new file mode 100644 index 000000000..8a5b22c46 --- /dev/null +++ b/tests/fixtures/iot/HS300(US)_2.0_1.0.3.json @@ -0,0 +1,90 @@ +{ + "emeter": { + "get_realtime": { + "current_ma": 0, + "err_code": 0, + "power_mw": 0, + "slot_id": 0, + "total_wh": 0, + "voltage_mv": 121302 + } + }, + "system": { + "get_sysinfo": { + "alias": "#MASKED_NAME#", + "child_num": 6, + "children": [ + { + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", + "next_action": { + "type": -1 + }, + "on_time": 0, + "state": 0 + }, + { + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", + "next_action": { + "type": -1 + }, + "on_time": 0, + "state": 0 + }, + { + "alias": "#MASKED_NAME# 3", + "id": "SCRUBBED_CHILD_DEVICE_ID_3", + "next_action": { + "type": -1 + }, + "on_time": 0, + "state": 0 + }, + { + "alias": "#MASKED_NAME# 4", + "id": "SCRUBBED_CHILD_DEVICE_ID_4", + "next_action": { + "type": -1 + }, + "on_time": 0, + "state": 0 + }, + { + "alias": "#MASKED_NAME# 5", + "id": "SCRUBBED_CHILD_DEVICE_ID_5", + "next_action": { + "type": -1 + }, + "on_time": 0, + "state": 0 + }, + { + "alias": "#MASKED_NAME# 6", + "id": "SCRUBBED_CHILD_DEVICE_ID_6", + "next_action": { + "type": -1 + }, + "on_time": 0, + "state": 0 + } + ], + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM:ENE", + "hwId": "00000000000000000000000000000000", + "hw_ver": "2.0", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "HS300(US)", + "oemId": "00000000000000000000000000000000", + "rssi": -55, + "status": "new", + "sw_ver": "1.0.3 Build 201203 Rel.165457", + "updating": 0 + } + } +} diff --git a/kasa/tests/fixtures/LB120(US)_1.0.json b/tests/fixtures/iot/KL110(US)_1.0_1.8.11.json similarity index 67% rename from kasa/tests/fixtures/LB120(US)_1.0.json rename to tests/fixtures/iot/KL110(US)_1.0_1.8.11.json index e9d1dce27..89b623bdf 100644 --- a/kasa/tests/fixtures/LB120(US)_1.0.json +++ b/tests/fixtures/iot/KL110(US)_1.0_1.8.11.json @@ -1,22 +1,14 @@ { - "emeter": { - "err_code": -1, - "err_msg": "module not support" - }, "smartlife.iot.common.emeter": { "get_realtime": { "err_code": 0, - "power_mw": 10800 + "power_mw": 0 } }, - "smartlife.iot.dimmer": { - "err_code": -1, - "err_msg": "module not support" - }, "smartlife.iot.smartbulb.lightingservice": { "get_light_state": { "dft_on_state": { - "brightness": 100, + "brightness": 95, "color_temp": 2700, "hue": 0, "mode": "normal", @@ -29,43 +21,37 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Mock lb120", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" }, - "description": "Smart Wi-Fi LED Bulb with Tunable White Light", + "description": "Smart Wi-Fi LED Bulb with Dimmable Light", "dev_state": "normal", - "deviceId": "62FD818E5B66A509D571D07D0F00FA4DD6468494", + "deviceId": "0000000000000000000000000000000000000000", "disco_ver": "1.0", "err_code": 0, - "heapsize": 302452, - "hwId": "CC0588817E251DF996F1848ED331F543", + "heapsize": 291620, + "hwId": "00000000000000000000000000000000", "hw_ver": "1.0", "is_color": 0, "is_dimmable": 1, "is_factory": false, - "is_variable_color_temp": 1, - "latitude": -76.9197, - "latitude_i": -76.9197, + "is_variable_color_temp": 0, "light_state": { "dft_on_state": { - "brightness": 100, + "brightness": 95, "color_temp": 2700, "hue": 0, "mode": "normal", "saturation": 0 }, - "err_code": 0, "on_off": 0 }, - "longitude": 164.7293, - "longitude_i": 164.7293, - "mac": "50:c7:bf:dc:62:13", + "mic_mac": "000000000000", "mic_type": "IOT.SMARTBULB", - "model": "LB120(US)", - "oemId": "05D0D97951F565579A7F5A70A57AED0B", - "on_time": 0, + "model": "KL110(US)", + "oemId": "00000000000000000000000000000000", "preferred_state": [ { "brightness": 100, @@ -96,8 +82,8 @@ "saturation": 0 } ], - "rssi": -65, - "sw_ver": "1.1.0 Build 160630 Rel.085319" + "rssi": -64, + "sw_ver": "1.8.11 Build 191113 Rel.105336" } } } 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 new file mode 100644 index 000000000..0bbc9886b --- /dev/null +++ b/tests/fixtures/iot/KL120(US)_1.0_1.8.11.json @@ -0,0 +1,85 @@ +{ + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 7800 + } + }, + "smartlife.iot.smartbulb.lightingservice": { + "get_light_state": { + "brightness": 70, + "color_temp": 3001, + "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 Tunable White Light", + "dev_state": "normal", + "deviceId": "0000000000000000000000000000000000000000", + "disco_ver": "1.0", + "err_code": 0, + "heapsize": 292140, + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_color": 0, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 1, + "light_state": { + "brightness": 70, + "color_temp": 3001, + "hue": 0, + "mode": "normal", + "on_off": 1, + "saturation": 0 + }, + "mic_mac": "000000000000", + "mic_type": "IOT.SMARTBULB", + "model": "KL120(US)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [ + { + "brightness": 100, + "color_temp": 3500, + "hue": 0, + "index": 0, + "saturation": 0 + }, + { + "brightness": 50, + "color_temp": 5000, + "hue": 0, + "index": 1, + "saturation": 0 + }, + { + "brightness": 50, + "color_temp": 2700, + "hue": 0, + "index": 2, + "saturation": 0 + }, + { + "brightness": 1, + "color_temp": 2700, + "hue": 0, + "index": 3, + "saturation": 0 + } + ], + "rssi": -45, + "sw_ver": "1.8.11 Build 191113 Rel.105336" + } + } +} diff --git a/kasa/tests/fixtures/KL120(US)_1.0_real.json b/tests/fixtures/iot/KL120(US)_1.0_1.8.6.json similarity index 91% rename from kasa/tests/fixtures/KL120(US)_1.0_real.json rename to tests/fixtures/iot/KL120(US)_1.0_1.8.6.json index c251f2fa6..50bd202ee 100644 --- a/kasa/tests/fixtures/KL120(US)_1.0_real.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 new file mode 100644 index 000000000..aedcb1f68 --- /dev/null +++ b/tests/fixtures/iot/KL125(US)_1.20_1.0.5.json @@ -0,0 +1,88 @@ +{ + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 10800, + "total_wh": 40 + } + }, + "smartlife.iot.smartbulb.lightingservice": { + "get_light_state": { + "brightness": 100, + "color_temp": 4000, + "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 Color Changing", + "dev_state": "normal", + "deviceId": "0000000000000000000000000000000000000000", + "disco_ver": "1.0", + "err_code": 0, + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.20", + "is_color": 1, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 1, + "latitude_i": 0, + "light_state": { + "brightness": 100, + "color_temp": 4000, + "hue": 0, + "mode": "normal", + "on_off": 1, + "saturation": 0 + }, + "longitude_i": 0, + "mic_mac": "000000000000", + "mic_type": "IOT.SMARTBULB", + "model": "KL125(US)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [ + { + "brightness": 100, + "color_temp": 4000, + "hue": 0, + "index": 0, + "saturation": 0 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 0, + "index": 1, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 120, + "index": 2, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 240, + "index": 3, + "saturation": 100 + } + ], + "rssi": -59, + "status": "new", + "sw_ver": "1.0.5 Build 200831 Rel.141525" + } + } +} diff --git a/kasa/tests/fixtures/LB130(US)_1.0.json b/tests/fixtures/iot/KL125(US)_2.0_1.0.7.json similarity index 51% rename from kasa/tests/fixtures/LB130(US)_1.0.json rename to tests/fixtures/iot/KL125(US)_2.0_1.0.7.json index dcd441cea..9d19ca576 100644 --- a/kasa/tests/fixtures/LB130(US)_1.0.json +++ b/tests/fixtures/iot/KL125(US)_2.0_1.0.7.json @@ -1,26 +1,19 @@ { - "emeter": { - "err_code": -1, - "err_msg": "module not support" - }, "smartlife.iot.common.emeter": { "get_realtime": { "err_code": 0, - "power_mw": 10800 + "power_mw": 0, + "total_wh": 238 } }, - "smartlife.iot.dimmer": { - "err_code": -1, - "err_msg": "module not support" - }, "smartlife.iot.smartbulb.lightingservice": { "get_light_state": { "dft_on_state": { "brightness": 100, - "color_temp": 2700, - "hue": 0, + "color_temp": 2500, + "hue": 255, "mode": "normal", - "saturation": 0 + "saturation": 100 }, "err_code": 0, "on_off": 0 @@ -29,76 +22,71 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Mock lb130", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" }, "description": "Smart Wi-Fi LED Bulb with Color Changing", "dev_state": "normal", - "deviceId": "50BE9E7B6F26CA75D495C13EAA459C491768F143", + "deviceId": "0000000000000000000000000000000000000000", "disco_ver": "1.0", "err_code": 0, - "heapsize": 302452, - "hwId": "C8AD962B53417C2845CC10CE25C00BB1", - "hw_ver": "1.0", + "hwId": "00000000000000000000000000000000", + "hw_ver": "2.0", "is_color": 1, "is_dimmable": 1, "is_factory": false, "is_variable_color_temp": 1, - "latitude": 76.8649, - "latitude_i": 76.8649, + "latitude_i": 0, "light_state": { "dft_on_state": { "brightness": 100, - "color_temp": 2700, - "hue": 0, + "color_temp": 2500, + "hue": 255, "mode": "normal", - "saturation": 0 + "saturation": 100 }, - "err_code": 0, "on_off": 0 }, - "longitude": -40.7284, - "longitude_i": -40.7284, - "INVALIDmac": "50:c7:bf:ac:f6:19", - "mic_mac": "50C7BFACF619", + "longitude_i": 0, + "mic_mac": "000000000000", "mic_type": "IOT.SMARTBULB", - "model": "LB130(US)", - "oemId": "CF78964560AAB75A43F15D2E468B63EF", - "on_time": 0, + "model": "KL125(US)", + "oemId": "00000000000000000000000000000000", "preferred_state": [ { - "brightness": 100, + "brightness": 50, "color_temp": 2700, "hue": 0, "index": 0, "saturation": 0 }, { - "brightness": 75, - "color_temp": 2700, + "brightness": 100, + "color_temp": 0, "hue": 0, "index": 1, - "saturation": 0 + "saturation": 100 }, { - "brightness": 25, - "color_temp": 2700, - "hue": 0, + "brightness": 100, + "color_temp": 0, + "hue": 120, "index": 2, - "saturation": 0 + "saturation": 100 }, { - "brightness": 1, - "color_temp": 2700, - "hue": 0, + "brightness": 100, + "color_temp": 0, + "hue": 240, "index": 3, - "saturation": 0 + "saturation": 100 } ], - "rssi": -65, - "sw_ver": "1.6.0 Build 170703 Rel.141938" + "rssi": -63, + "status": "new", + "sw_ver": "1.0.7 Build 210811 Rel.171439" } } } diff --git a/tests/fixtures/iot/KL125(US)_4.0_1.0.5.json b/tests/fixtures/iot/KL125(US)_4.0_1.0.5.json new file mode 100644 index 000000000..b098dbda1 --- /dev/null +++ b/tests/fixtures/iot/KL125(US)_4.0_1.0.5.json @@ -0,0 +1,93 @@ +{ + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 0, + "total_wh": 0 + } + }, + "smartlife.iot.smartbulb.lightingservice": { + "get_light_state": { + "dft_on_state": { + "brightness": 84, + "color_temp": 0, + "hue": 9, + "mode": "normal", + "saturation": 67 + }, + "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, + "hwId": "00000000000000000000000000000000", + "hw_ver": "4.0", + "is_color": 1, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 1, + "latitude_i": 0, + "light_state": { + "dft_on_state": { + "brightness": 84, + "color_temp": 0, + "hue": 9, + "mode": "normal", + "saturation": 67 + }, + "on_off": 0 + }, + "longitude_i": 0, + "mic_mac": "5091E3000000", + "mic_type": "IOT.SMARTBULB", + "model": "KL125(US)", + "obd_src": "tplink", + "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": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 120, + "index": 2, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 240, + "index": 3, + "saturation": 100 + } + ], + "rssi": -37, + "status": "new", + "sw_ver": "1.0.5 Build 230613 Rel.151643" + } + } +} 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 new file mode 100644 index 000000000..ce3034629 --- /dev/null +++ b/tests/fixtures/iot/KL130(EU)_1.0_1.8.8.json @@ -0,0 +1,150 @@ +{ + "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": [ + { + "fwLocation": 0, + "fwReleaseDate": "2019-11-27", + "fwReleaseLog": "New Features/Enhancements:\n1. Added the offset feature when scheduling sunset/sunrise.\n2. Improved the overall performance of schedule feature.", + "fwReleaseLogUrl": "undefined yet", + "fwTitle": "Hi, a new firmware with bug fixes is available for your product.", + "fwType": 0, + "fwUrl": "http://download.tplinkcloud.com/firmware/smartBulb_FCC_1.8.11_Build_191113_Rel.105336__1574839035801.bin", + "fwVer": "1.8.11 Build 191113 Rel.105336" + } + ] + } + }, + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 10800 + } + }, + "smartlife.iot.common.schedule": { + "get_next_action": { + "err_code": 0, + "type": -1 + }, + "get_rules": { + "enable": 1, + "err_code": 0, + "rule_list": [] + } + }, + "smartlife.iot.smartbulb.lightingservice": { + "get_default_behavior": { + "err_code": 0, + "hard_on": { + "mode": "circadian" + }, + "soft_on": { + "brightness": 50, + "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": 150, + "max_lumens": 800, + "max_voltage": 120, + "min_voltage": 110, + "wattage": 10 + }, + "get_light_state": { + "brightness": 100, + "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 Color Changing", + "dev_state": "normal", + "deviceId": "0000000000000000000000000000000000000000", + "disco_ver": "1.0", + "err_code": 0, + "heapsize": 308144, + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_color": 1, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 1, + "light_state": { + "brightness": 100, + "color_temp": 2700, + "hue": 0, + "mode": "normal", + "on_off": 1, + "saturation": 0 + }, + "mic_mac": "1C3BF3000000", + "mic_type": "IOT.SMARTBULB", + "model": "KL130(EU)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [ + { + "brightness": 50, + "color_temp": 2700, + "hue": 0, + "index": 0, + "saturation": 0 + }, + { + "brightness": 20, + "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": -60, + "sw_ver": "1.8.8 Build 190613 Rel.123436" + } + } +} diff --git a/kasa/tests/fixtures/KL130(US)_1.0.json b/tests/fixtures/iot/KL130(US)_1.0_1.8.11.json similarity index 78% rename from kasa/tests/fixtures/KL130(US)_1.0.json rename to tests/fixtures/iot/KL130(US)_1.0_1.8.11.json index b07044a65..d9eaaca16 100644 --- a/kasa/tests/fixtures/KL130(US)_1.0.json +++ b/tests/fixtures/iot/KL130(US)_1.0_1.8.11.json @@ -1,33 +1,27 @@ { - "emeter": { - "err_code": -2001, - "err_msg": "Module not support" - }, "smartlife.iot.common.emeter": { "get_realtime": { "err_code": 0, - "power_mw": 800 + "power_mw": 0 } }, - "smartlife.iot.dimmer": { - "err_code": -2001, - "err_msg": "Module not support" - }, "smartlife.iot.smartbulb.lightingservice": { "get_light_state": { - "brightness": 0, - "color_temp": 0, + "dft_on_state": { + "brightness": 30, + "color_temp": 0, + "hue": 240, + "mode": "normal", + "saturation": 100 + }, "err_code": 0, - "hue": 15, - "mode": "normal", - "on_off": 1, - "saturation": 100 + "on_off": 0 } }, "system": { "get_sysinfo": { "active_mode": "none", - "alias": "KL130 office bulb", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" @@ -37,7 +31,7 @@ "deviceId": "0000000000000000000000000000000000000000", "disco_ver": "1.0", "err_code": 0, - "heapsize": 306332, + "heapsize": 305252, "hwId": "00000000000000000000000000000000", "hw_ver": "1.0", "is_color": 1, @@ -45,14 +39,16 @@ "is_factory": false, "is_variable_color_temp": 1, "light_state": { - "brightness": 30, - "color_temp": 0, - "hue": 15, - "mode": "normal", - "on_off": 1, - "saturation": 100 + "dft_on_state": { + "brightness": 30, + "color_temp": 0, + "hue": 240, + "mode": "normal", + "saturation": 100 + }, + "on_off": 0 }, - "mic_mac": "1C3BF373CA4E", + "mic_mac": "000000000000", "mic_type": "IOT.SMARTBULB", "model": "KL130(US)", "oemId": "00000000000000000000000000000000", @@ -86,7 +82,7 @@ "saturation": 75 } ], - "rssi": -62, + "rssi": -52, "sw_ver": "1.8.11 Build 191113 Rel.105336" } } 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 new file mode 100644 index 000000000..38a8805d0 --- /dev/null +++ b/tests/fixtures/iot/KL135(US)_1.0_1.0.15.json @@ -0,0 +1,140 @@ +{ + "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": 10800, + "total_wh": 48 + } + }, + "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" + }, + "re_power_type": "always_on", + "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": { + "brightness": 100, + "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 Color Changing", + "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, + "light_state": { + "brightness": 100, + "color_temp": 2700, + "hue": 0, + "mode": "normal", + "on_off": 1, + "saturation": 0 + }, + "longitude_i": 0, + "mic_mac": "54AF97000000", + "mic_type": "IOT.SMARTBULB", + "model": "KL135(US)", + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "preferred_state": [ + { + "brightness": 100, + "color_temp": 2700, + "hue": 0, + "index": 0, + "saturation": 0 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 0, + "index": 1, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 120, + "index": 2, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 240, + "index": 3, + "saturation": 100 + } + ], + "rssi": -69, + "status": "new", + "sw_ver": "1.0.15 Build 240429 Rel.154143" + } + } +} 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 new file mode 100644 index 000000000..be34f9c5b --- /dev/null +++ b/tests/fixtures/iot/KL135(US)_1.0_1.0.6.json @@ -0,0 +1,89 @@ +{ + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 900, + "total_wh": 0 + } + }, + "smartlife.iot.smartbulb.lightingservice": { + "get_light_state": { + "brightness": 1, + "color_temp": 0, + "err_code": 0, + "hue": 37, + "mode": "normal", + "on_off": 1, + "saturation": 100 + } + }, + "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, + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_color": 1, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 1, + "latitude_i": 0, + "light_state": { + "brightness": 1, + "color_temp": 0, + "hue": 37, + "mode": "normal", + "on_off": 1, + "saturation": 100 + }, + "longitude_i": 0, + "mic_mac": "000000000000", + "mic_type": "IOT.SMARTBULB", + "model": "KL135(US)", + "obd_src": "tplink", + "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": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 120, + "index": 2, + "saturation": 100 + }, + { + "brightness": 1, + "color_temp": 0, + "hue": 37, + "index": 3, + "saturation": 100 + } + ], + "rssi": -69, + "status": "new", + "sw_ver": "1.0.6 Build 210330 Rel.173743" + } + } +} 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/kasa/tests/fixtures/KL430(US)_1.0.json b/tests/fixtures/iot/KL400L5(US)_1.0_1.0.5.json similarity index 63% rename from kasa/tests/fixtures/KL430(US)_1.0.json rename to tests/fixtures/iot/KL400L5(US)_1.0_1.0.5.json index f12e7d500..1bcd088b7 100644 --- a/kasa/tests/fixtures/KL430(US)_1.0.json +++ b/tests/fixtures/iot/KL400L5(US)_1.0_1.0.5.json @@ -1,29 +1,16 @@ { - "emeter": { - "err_code": -1, - "err_msg": "module not support" - }, "smartlife.iot.common.emeter": { "get_realtime": { - "current_ma": 0, "err_code": 0, - "power_mw": 8729, - "total_wh": 21, - "voltage_mv": 0 + "power_mw": 10800, + "total_wh": 0 } }, - "smartlife.iot.dimmer": { - "err_code": -1, - "err_msg": "module not support" - }, - "smartlife.iot.smartbulb.lightingservice": { - "err_code": -1, - "err_msg": "module not support" - }, "system": { "get_sysinfo": { + "LEF": 0, "active_mode": "none", - "alias": "KL430 pantry lightstrip", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" @@ -38,12 +25,12 @@ "is_color": 1, "is_dimmable": 1, "is_factory": false, - "is_variable_color_temp": 1, + "is_variable_color_temp": 0, "latitude_i": 0, "length": 16, "light_state": { - "brightness": 50, - "color_temp": 3630, + "brightness": 100, + "color_temp": 6500, "hue": 0, "mode": "normal", "on_off": 1, @@ -54,17 +41,17 @@ "custom": 0, "enable": 0, "id": "", - "name": "" + "name": "station" }, "longitude_i": 0, - "mic_mac": "CC32E5230F55", + "mic_mac": "00:00:00:00:00:00", "mic_type": "IOT.SMARTBULB", - "model": "KL430(US)", + "model": "KL400L5(US)", "oemId": "00000000000000000000000000000000", "preferred_state": [], - "rssi": -56, + "rssi": -58, "status": "new", - "sw_ver": "1.0.10 Build 200522 Rel.104340" + "sw_ver": "1.0.5 Build 210616 Rel.122727" } } } 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 new file mode 100644 index 000000000..6a15c16c3 --- /dev/null +++ b/tests/fixtures/iot/KL400L5(US)_1.0_1.0.8.json @@ -0,0 +1,57 @@ +{ + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 10800, + "total_wh": 1 + } + }, + "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": 100, + "color_temp": 6500, + "hue": 0, + "mode": "normal", + "on_off": 1, + "saturation": 0 + }, + "lighting_effect_state": { + "brightness": 100, + "custom": 0, + "enable": 1, + "id": "CdLeIgiKcQrLKMINRPTMbylATulQewLD", + "name": "Hanukkah" + }, + "longitude_i": 0, + "mic_mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTBULB", + "model": "KL400L5(US)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [], + "rssi": -44, + "status": "new", + "sw_ver": "1.0.8 Build 211018 Rel.162056" + } + } +} 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 new file mode 100644 index 000000000..2d16adba5 --- /dev/null +++ b/tests/fixtures/iot/KL420L5(US)_1.0_1.0.2.json @@ -0,0 +1,57 @@ +{ + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 1503, + "total_wh": 0 + } + }, + "system": { + "get_sysinfo": { + "LEF": 1, + "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": 50, + "light_state": { + "brightness": 100, + "color_temp": 6500, + "hue": 0, + "mode": "normal", + "on_off": 1, + "saturation": 0 + }, + "lighting_effect_state": { + "brightness": 50, + "custom": 0, + "enable": 0, + "id": "", + "name": "station" + }, + "longitude_i": 0, + "mic_mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTBULB", + "model": "KL420L5(US)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [], + "rssi": -44, + "status": "new", + "sw_ver": "1.0.2 Build 211009 Rel.164949" + } + } +} 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 new file mode 100644 index 000000000..8a924c197 --- /dev/null +++ b/tests/fixtures/iot/KL430(UN)_2.0_1.0.8.json @@ -0,0 +1,90 @@ +{ + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 16760, + "total_wh": 120 + } + }, + "system": { + "get_sysinfo": { + "LEF": 1, + "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": "2.0", + "is_color": 1, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 1, + "latitude_i": 0, + "length": 16, + "light_state": { + "brightness": 100, + "color_temp": 9000, + "hue": 0, + "mode": "normal", + "on_off": 1, + "saturation": 0 + }, + "lighting_effect_state": { + "brightness": 100, + "custom": 1, + "enable": 1, + "id": "yMwcNpLxijmoKamskHCvvravpbnIqAIN", + "name": "Aurora 1" + }, + "longitude_i": 0, + "mic_mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTBULB", + "model": "KL430(UN)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [ + { + "brightness": 100, + "color_temp": 9000, + "hue": 0, + "index": 0, + "mode": 1, + "saturation": 0 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 217, + "index": 1, + "mode": 1, + "saturation": 99 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 194, + "index": 2, + "mode": 1, + "saturation": 50 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 277, + "index": 3, + "mode": 1, + "saturation": 86 + } + ], + "rssi": -43, + "status": "new", + "sw_ver": "1.0.8 Build 210121 Rel.084339" + } + } +} 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 new file mode 100644 index 000000000..5bda57627 --- /dev/null +++ b/tests/fixtures/iot/KL430(US)_1.0_1.0.10.json @@ -0,0 +1,109 @@ +{ + "emeter": { + "err_code": -1, + "err_msg": "module not support" + }, + "smartlife.iot.common.emeter": { + "get_realtime": { + "current_ma": 0, + "err_code": 0, + "power_mw": 2725, + "total_wh": 1193, + "voltage_mv": 0 + } + }, + "smartlife.iot.dimmer": { + "err_code": -1, + "err_msg": "module not support" + }, + "smartlife.iot.smartbulb.lightingservice": { + "err_code": -1, + "err_msg": "module not support" + }, + "system": { + "get_sysinfo": { + "active_mode": "schedule", + "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": 16, + "light_state": { + "brightness": 15, + "color_temp": 2500, + "hue": 0, + "mode": "normal", + "on_off": 1, + "saturation": 0 + }, + "lighting_effect_state": { + "brightness": 100, + "custom": 0, + "enable": 0, + "id": "bCTItKETDFfrKANolgldxfgOakaarARs", + "name": "Flicker" + }, + "longitude_i": 0, + "mic_mac": "CC32E5000000", + "mic_type": "IOT.SMARTBULB", + "model": "KL430(US)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [ + { + "brightness": 100, + "custom": 0, + "id": "QglBhMShPHUAuxLqzNEefFrGiJwahOmz", + "index": 0, + "mode": 2 + }, + { + "brightness": 100, + "custom": 0, + "id": "bCTItKETDFfrKANolgldxfgOakaarARs", + "index": 1, + "mode": 2 + }, + { + "brightness": 34, + "color_temp": 0, + "hue": 7, + "index": 2, + "mode": 1, + "saturation": 49 + }, + { + "brightness": 25, + "color_temp": 0, + "hue": 4, + "index": 3, + "mode": 1, + "saturation": 100 + }, + { + "brightness": 15, + "color_temp": 2500, + "hue": 0, + "index": 4, + "mode": 1, + "saturation": 0 + } + ], + "rssi": -44, + "status": "new", + "sw_ver": "1.0.10 Build 200522 Rel.104340" + } + } +} 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 new file mode 100644 index 000000000..380250ff3 --- /dev/null +++ b/tests/fixtures/iot/KL430(US)_2.0_1.0.11.json @@ -0,0 +1,141 @@ +{ + "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": [ + { + "fwLocation": 0, + "fwReleaseDate": "2024-06-28", + "fwReleaseLog": "Modifications and Bug Fixes:\n1. Enhanced device stability.\n2. Fixed the problem that Color Painting doesn't work properly in some cases.\n3. Fixed some minor bugs.", + "fwReleaseLogUrl": "undefined yet", + "fwTitle": "Hi, a new firmware with bug fixes is available for your product.", + "fwType": 1, + "fwUrl": "http://download.tplinkcloud.com/firmware/KLM430v2_FCC_KL430_1.0.12_Build_240227_Rel.160022_2024-02-27_16.01.59_1719559326313.bin", + "fwVer": "1.0.12 Build 240227 Rel.160022" + } + ] + } + }, + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 600, + "total_wh": 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.lightStrip": { + "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": 180, + "max_lumens": 800, + "max_voltage": 120, + "min_voltage": 100, + "wattage": 10 + }, + "get_light_state": { + "dft_on_state": { + "groups": [ + [ + 0, + 15, + 0, + 0, + 100, + 3842 + ] + ], + "mode": "normal" + }, + "err_code": 0, + "length": 16, + "on_off": 0, + "transition": 500 + } + }, + "system": { + "get_sysinfo": { + "LEF": 1, + "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": "2.0", + "is_color": 1, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 1, + "latitude_i": 0, + "length": 16, + "light_state": { + "dft_on_state": { + "brightness": 100, + "color_temp": 3842, + "hue": 0, + "mode": "normal", + "saturation": 0 + }, + "on_off": 0 + }, + "lighting_effect_state": { + "brightness": 100, + "custom": 0, + "enable": 0, + "id": "bCTItKETDFfrKANolgldxfgOakaarARs", + "name": "Flicker" + }, + "longitude_i": 0, + "mic_mac": "E8:48:B8:00:00:00", + "mic_type": "IOT.SMARTBULB", + "model": "KL430(US)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [], + "rssi": -35, + "status": "new", + "sw_ver": "1.0.11 Build 220812 Rel.153345" + } + } +} 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 new file mode 100644 index 000000000..c5cf550bd --- /dev/null +++ b/tests/fixtures/iot/KL430(US)_2.0_1.0.8.json @@ -0,0 +1,59 @@ +{ + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 600, + "total_wh": 0 + } + }, + "system": { + "get_sysinfo": { + "LEF": 1, + "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": "2.0", + "is_color": 1, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 1, + "latitude_i": 0, + "length": 16, + "light_state": { + "dft_on_state": { + "brightness": 50, + "color_temp": 0, + "hue": 30, + "mode": "normal", + "saturation": 100 + }, + "on_off": 0 + }, + "lighting_effect_state": { + "brightness": 50, + "custom": 0, + "enable": 0, + "id": "", + "name": "station" + }, + "longitude_i": 0, + "mic_mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTBULB", + "model": "KL430(US)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [], + "rssi": -61, + "status": "new", + "sw_ver": "1.0.8 Build 210121 Rel.084339" + } + } +} 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 new file mode 100644 index 000000000..2d9f7535f --- /dev/null +++ b/tests/fixtures/iot/KL430(US)_2.0_1.0.9.json @@ -0,0 +1,57 @@ +{ + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 11150, + "total_wh": 18 + } + }, + "system": { + "get_sysinfo": { + "LEF": 1, + "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": "2.0", + "is_color": 1, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 1, + "latitude_i": 0, + "length": 16, + "light_state": { + "brightness": 100, + "color_temp": 0, + "hue": 194, + "mode": "normal", + "on_off": 1, + "saturation": 50 + }, + "lighting_effect_state": { + "brightness": 100, + "custom": 0, + "enable": 1, + "id": "izRhLCQNcDzIKdpMPqSTtBMuAIoreAuT", + "name": "Rainbow" + }, + "longitude_i": 0, + "mic_mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTBULB", + "model": "KL430(US)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [], + "rssi": -58, + "status": "new", + "sw_ver": "1.0.9 Build 210915 Rel.170534" + } + } +} 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 new file mode 100644 index 000000000..6e30c136d --- /dev/null +++ b/tests/fixtures/iot/KL50(US)_1.0_1.1.13.json @@ -0,0 +1,90 @@ +{ + "smartlife.iot.common.emeter": { + "get_realtime": { + "current_ma": 0, + "err_code": 0, + "power_mw": 998, + "total_wh": 1, + "voltage_mv": 0 + } + }, + "smartlife.iot.smartbulb.lightingservice": { + "get_light_state": { + "brightness": 12, + "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": "Kasa Smart Edison Bulb, Dimmable", + "dev_state": "normal", + "deviceId": "0000000000000000000000000000000000000000", + "disco_ver": "1.0", + "err_code": 0, + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_color": 0, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 0, + "latitude_i": 0, + "light_state": { + "brightness": 12, + "color_temp": 2700, + "hue": 0, + "mode": "normal", + "on_off": 1, + "saturation": 0 + }, + "longitude_i": 0, + "mic_mac": "000000000000", + "mic_type": "IOT.SMARTBULB", + "model": "KL50(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": -68, + "status": "new", + "sw_ver": "1.1.13 Build 210524 Rel.082619" + } + } +} diff --git a/kasa/tests/fixtures/KL60(UN)_1.0.json b/tests/fixtures/iot/KL60(UN)_1.0_1.1.4.json similarity index 97% rename from kasa/tests/fixtures/KL60(UN)_1.0.json rename to tests/fixtures/iot/KL60(UN)_1.0_1.1.4.json index fa842b47c..22dadaee2 100644 --- a/kasa/tests/fixtures/KL60(UN)_1.0.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 new file mode 100644 index 000000000..6834d925d --- /dev/null +++ b/tests/fixtures/iot/KL60(US)_1.0_1.1.13.json @@ -0,0 +1,90 @@ +{ + "smartlife.iot.common.emeter": { + "get_realtime": { + "current_ma": 0, + "err_code": 0, + "power_mw": 5200, + "total_wh": 0, + "voltage_mv": 0 + } + }, + "smartlife.iot.smartbulb.lightingservice": { + "get_light_state": { + "brightness": 100, + "color_temp": 2000, + "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": "Kasa Smart Edison Bulb, Dimmable", + "dev_state": "normal", + "deviceId": "0000000000000000000000000000000000000000", + "disco_ver": "1.0", + "err_code": 0, + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_color": 0, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 0, + "latitude_i": 0, + "light_state": { + "brightness": 100, + "color_temp": 2000, + "hue": 0, + "mode": "normal", + "on_off": 1, + "saturation": 0 + }, + "longitude_i": 0, + "mic_mac": "000000000000", + "mic_type": "IOT.SMARTBULB", + "model": "KL60(US)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [ + { + "brightness": 100, + "color_temp": 2000, + "hue": 0, + "index": 0, + "saturation": 0 + }, + { + "brightness": 75, + "color_temp": 2000, + "hue": 0, + "index": 1, + "saturation": 0 + }, + { + "brightness": 25, + "color_temp": 2000, + "hue": 0, + "index": 2, + "saturation": 0 + }, + { + "brightness": 1, + "color_temp": 2000, + "hue": 0, + "index": 3, + "saturation": 0 + } + ], + "rssi": -41, + "status": "new", + "sw_ver": "1.1.13 Build 210524 Rel.082619" + } + } +} 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 new file mode 100644 index 000000000..46e9ec4ee --- /dev/null +++ b/tests/fixtures/iot/KP100(US)_3.0_1.0.1.json @@ -0,0 +1,31 @@ +{ + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "dev_name": "Smart Wi-Fi Plug Mini", + "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": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KP100(US)", + "next_action": { + "type": -1 + }, + "oemId": "00000000000000000000000000000000", + "on_time": 523, + "relay_state": 1, + "rssi": -60, + "status": "new", + "sw_ver": "1.0.1 Build 200831 Rel.142128", + "updating": 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 new file mode 100644 index 000000000..91e310d3c --- /dev/null +++ b/tests/fixtures/iot/KP105(UK)_1.0_1.0.5.json @@ -0,0 +1,111 @@ +{ + "cnCloud": { + "get_info": { + "binded": 1, + "cld_connection": 0, + "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": -7, + "err_msg": "unknown error" + } + }, + "schedule": { + "get_next_action": { + "action": 1, + "err_code": 0, + "id": "0794F4729DB271627D1CF35A9A854030", + "schd_time": 68927, + "type": 2 + }, + "get_rules": { + "enable": 1, + "err_code": 0, + "rule_list": [ + { + "eact": -1, + "enable": 1, + "id": "8AA75A50A8440B17941D192BD9E01FFA", + "name": "name", + "repeat": 1, + "sact": 1, + "smin": 1027, + "soffset": 0, + "stime_opt": 2, + "wday": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "eact": -1, + "enable": 1, + "id": "9F62073CF69D8645173412283AD63A2C", + "name": "name", + "repeat": 1, + "sact": 0, + "smin": 504, + "soffset": 0, + "stime_opt": 1, + "wday": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + } + ], + "version": 2 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "count_down", + "alias": "#MASKED_NAME#", + "dev_name": "Smart Wi-Fi Plug", + "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": "D8:47:32:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KP105(UK)", + "next_action": { + "action": 1, + "id": "0794F4729DB271627D1CF35A9A854030", + "schd_sec": 68927, + "type": 2 + }, + "ntc_state": 0, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 7138, + "relay_state": 1, + "rssi": -77, + "status": "configured", + "sw_ver": "1.0.5 Build 191209 Rel.094735", + "updating": 0 + } + } +} diff --git a/tests/fixtures/iot/KP105(UK)_1.0_1.0.7.json b/tests/fixtures/iot/KP105(UK)_1.0_1.0.7.json new file mode 100644 index 000000000..a66dc9030 --- /dev/null +++ b/tests/fixtures/iot/KP105(UK)_1.0_1.0.7.json @@ -0,0 +1,33 @@ +{ + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "dev_name": "Smart Wi-Fi Plug", + "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": "D8:47:32:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KP105(UK)", + "next_action": { + "type": -1 + }, + "ntc_state": 0, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "relay_state": 0, + "rssi": -67, + "status": "configured", + "sw_ver": "1.0.7 Build 210506 Rel.153510", + "updating": 0 + } + } +} diff --git a/tests/fixtures/iot/KP115(EU)_1.0_1.0.16.json b/tests/fixtures/iot/KP115(EU)_1.0_1.0.16.json new file mode 100644 index 000000000..3b577bac5 --- /dev/null +++ b/tests/fixtures/iot/KP115(EU)_1.0_1.0.16.json @@ -0,0 +1,42 @@ +{ + "emeter": { + "get_realtime": { + "current_ma": 296, + "err_code": 0, + "power_mw": 63499, + "total_wh": 12068, + "voltage_mv": 230577 + } + }, + "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": 1, + "longitude_i": 0, + "mac": "C0:06:C3:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KP115(EU)", + "next_action": { + "type": -1 + }, + "ntc_state": 0, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 2078998, + "relay_state": 1, + "rssi": -49, + "status": "new", + "sw_ver": "1.0.16 Build 210205 Rel.163735", + "updating": 0 + } + } +} 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 new file mode 100644 index 000000000..fb5efac81 --- /dev/null +++ b/tests/fixtures/iot/KP115(US)_1.0_1.0.17.json @@ -0,0 +1,42 @@ +{ + "emeter": { + "get_realtime": { + "current_ma": 0, + "err_code": 0, + "power_mw": 0, + "total_wh": 0, + "voltage_mv": 121148 + } + }, + "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": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KP115(US)", + "next_action": { + "type": -1 + }, + "ntc_state": 0, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 301, + "relay_state": 1, + "rssi": -59, + "status": "new", + "sw_ver": "1.0.17 Build 210506 Rel.075231", + "updating": 0 + } + } +} diff --git a/tests/fixtures/iot/KP115(US)_1.0_1.0.21.json b/tests/fixtures/iot/KP115(US)_1.0_1.0.21.json new file mode 100644 index 000000000..f073e7923 --- /dev/null +++ b/tests/fixtures/iot/KP115(US)_1.0_1.0.21.json @@ -0,0 +1,42 @@ +{ + "emeter": { + "get_realtime": { + "current_ma": 0, + "err_code": 0, + "power_mw": 0, + "total_wh": 0, + "voltage_mv": 120652 + } + }, + "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": "54:AF:97:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KP115(US)", + "next_action": { + "type": -1 + }, + "ntc_state": 0, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "relay_state": 0, + "rssi": -60, + "status": "new", + "sw_ver": "1.0.21 Build 231129 Rel.171238", + "updating": 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 new file mode 100644 index 000000000..2bb0d21e3 --- /dev/null +++ b/tests/fixtures/iot/KP125(US)_1.0_1.0.6.json @@ -0,0 +1,42 @@ +{ + "emeter": { + "get_realtime": { + "current_ma": 978, + "err_code": 0, + "power_mw": 100277, + "total_wh": 12170, + "voltage_mv": 119425 + } + }, + "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": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KP125(US)", + "next_action": { + "type": -1 + }, + "ntc_state": 0, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 301, + "relay_state": 1, + "rssi": -41, + "status": "configured", + "sw_ver": "1.0.6 Build 210928 Rel.185924", + "updating": 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 new file mode 100644 index 000000000..40a57fd5e --- /dev/null +++ b/tests/fixtures/iot/KP200(US)_3.0_1.0.3.json @@ -0,0 +1,46 @@ +{ + "system": { + "get_sysinfo": { + "alias": "#MASKED_NAME#", + "child_num": 2, + "children": [ + { + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", + "next_action": { + "type": -1 + }, + "on_time": 43, + "state": 1 + }, + { + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", + "next_action": { + "type": -1 + }, + "on_time": 44, + "state": 1 + } + ], + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "3.0", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KP200(US)", + "ntc_state": 0, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "rssi": -50, + "status": "new", + "sw_ver": "1.0.3 Build 221021 Rel.183354", + "updating": 0 + } + } +} diff --git a/kasa/tests/fixtures/KP303(UK)_1.0.json b/tests/fixtures/iot/KP303(UK)_1.0_1.0.3.json similarity index 57% rename from kasa/tests/fixtures/KP303(UK)_1.0.json rename to tests/fixtures/iot/KP303(UK)_1.0_1.0.3.json index 40a6a2595..b5c6a1050 100644 --- a/kasa/tests/fixtures/KP303(UK)_1.0.json +++ b/tests/fixtures/iot/KP303(UK)_1.0_1.0.3.json @@ -1,50 +1,34 @@ { - "emeter": { - "err_code": -1, - "err_msg": "module not support" - }, - "smartlife.iot.common.emeter": { - "err_code": -1, - "err_msg": "module not support" - }, - "smartlife.iot.dimmer": { - "err_code": -1, - "err_msg": "module not support" - }, - "smartlife.iot.smartbulb.lightingservice": { - "err_code": -1, - "err_msg": "module not support" - }, "system": { "get_sysinfo": { - "alias": "TP-LINK_Power Strip_CF69", + "alias": "#MASKED_NAME#", "child_num": 3, "children": [ { - "alias": "Plug 1", - "id": "00", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "type": -1 }, - "on_time": 302, + "on_time": 79701, "state": 1 }, { - "alias": "Plug 2", - "id": "01", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, - "on_time": 0, + "on_time": 79700, "state": 0 }, { - "alias": "Plug 3", - "id": "02", + "alias": "#MASKED_NAME# 3", + "id": "SCRUBBED_CHILD_DEVICE_ID_3", "next_action": { "type": -1 }, - "on_time": 0, + "on_time": 1484408, "state": 0 } ], @@ -54,14 +38,14 @@ "hwId": "00000000000000000000000000000000", "hw_ver": "1.0", "latitude_i": 0, - "led_off": 0, + "led_off": 1, "longitude_i": 0, - "mac": "00:00:00:00:00:00", + "mac": "1C:3B:F3:00:00:00", "mic_type": "IOT.SMARTPLUGSWITCH", "model": "KP303(UK)", "ntc_state": 0, "oemId": "00000000000000000000000000000000", - "rssi": -63, + "rssi": -68, "status": "new", "sw_ver": "1.0.3 Build 191105 Rel.113122", "updating": 0 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 new file mode 100644 index 000000000..a95905579 --- /dev/null +++ b/tests/fixtures/iot/KP303(US)_2.0_1.0.3.json @@ -0,0 +1,54 @@ +{ + "system": { + "get_sysinfo": { + "alias": "#MASKED_NAME#", + "child_num": 3, + "children": [ + { + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", + "next_action": { + "type": -1 + }, + "on_time": 0, + "state": 0 + }, + { + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", + "next_action": { + "type": -1 + }, + "on_time": 0, + "state": 0 + }, + { + "alias": "#MASKED_NAME# 3", + "id": "SCRUBBED_CHILD_DEVICE_ID_3", + "next_action": { + "type": -1 + }, + "on_time": 0, + "state": 0 + } + ], + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "2.0", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KP303(US)", + "ntc_state": 0, + "oemId": "00000000000000000000000000000000", + "rssi": -56, + "status": "new", + "sw_ver": "1.0.3 Build 201015 Rel.173920", + "updating": 0 + } + } +} 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 new file mode 100644 index 000000000..333df3f6c --- /dev/null +++ b/tests/fixtures/iot/KP303(US)_2.0_1.0.9.json @@ -0,0 +1,55 @@ +{ + "system": { + "get_sysinfo": { + "alias": "#MASKED_NAME#", + "child_num": 3, + "children": [ + { + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", + "next_action": { + "type": -1 + }, + "on_time": 1461030, + "state": 1 + }, + { + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", + "next_action": { + "type": -1 + }, + "on_time": 0, + "state": 0 + }, + { + "alias": "#MASKED_NAME# 3", + "id": "SCRUBBED_CHILD_DEVICE_ID_3", + "next_action": { + "type": -1 + }, + "on_time": 0, + "state": 0 + } + ], + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "2.0", + "latitude_i": 0, + "led_off": 1, + "longitude_i": 0, + "mac": "B0:A7:B9:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KP303(US)", + "ntc_state": 0, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "rssi": -58, + "status": "new", + "sw_ver": "1.0.9 Build 240131 Rel.141407", + "updating": 0 + } + } +} diff --git a/kasa/tests/fixtures/KP400(US)_1.0.json b/tests/fixtures/iot/KP400(US)_1.0_1.0.10.json similarity index 87% rename from kasa/tests/fixtures/KP400(US)_1.0.json rename to tests/fixtures/iot/KP400(US)_1.0_1.0.10.json index afdb7bfcd..cd09a434c 100644 --- a/kasa/tests/fixtures/KP400(US)_1.0.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 new file mode 100644 index 000000000..3f838a91c --- /dev/null +++ b/tests/fixtures/iot/KP400(US)_2.0_1.0.6.json @@ -0,0 +1,45 @@ +{ + "system": { + "get_sysinfo": { + "alias": "#MASKED_NAME#", + "child_num": 2, + "children": [ + { + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", + "next_action": { + "type": -1 + }, + "on_time": 313, + "state": 1 + }, + { + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", + "next_action": { + "type": -1 + }, + "on_time": 313, + "state": 1 + } + ], + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "2.0", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KP400(US)", + "ntc_state": 0, + "oemId": "00000000000000000000000000000000", + "rssi": -46, + "status": "new", + "sw_ver": "1.0.6 Build 200821 Rel.090909", + "updating": 0 + } + } +} 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 new file mode 100644 index 000000000..ec1c37f36 --- /dev/null +++ b/tests/fixtures/iot/KP400(US)_3.0_1.0.3.json @@ -0,0 +1,45 @@ +{ + "system": { + "get_sysinfo": { + "alias": "#MASKED_NAME#", + "child_num": 2, + "children": [ + { + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", + "next_action": { + "type": -1 + }, + "on_time": 4024, + "state": 1 + }, + { + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", + "next_action": { + "type": -1 + }, + "on_time": 4024, + "state": 1 + } + ], + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "3.0", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "3C:52:A1:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KP400(US)", + "ntc_state": 0, + "oemId": "00000000000000000000000000000000", + "rssi": -75, + "status": "new", + "sw_ver": "1.0.3 Build 220803 Rel.172301", + "updating": 0 + } + } +} 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 new file mode 100644 index 000000000..5a60a4003 --- /dev/null +++ b/tests/fixtures/iot/KP400(US)_3.0_1.0.4.json @@ -0,0 +1,46 @@ +{ + "system": { + "get_sysinfo": { + "alias": "#MASKED_NAME#", + "child_num": 2, + "children": [ + { + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", + "next_action": { + "type": -1 + }, + "on_time": 0, + "state": 0 + }, + { + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", + "next_action": { + "type": -1 + }, + "on_time": 0, + "state": 0 + } + ], + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "3.0", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "3C:52:A1:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KP400(US)", + "ntc_state": 0, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "rssi": -69, + "status": "new", + "sw_ver": "1.0.4 Build 240305 Rel.111944", + "updating": 0 + } + } +} 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 new file mode 100644 index 000000000..f3006cf49 --- /dev/null +++ b/tests/fixtures/iot/KP401(US)_1.0_1.0.0.json @@ -0,0 +1,32 @@ +{ + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "dev_name": "Smart Outdoor Plug", + "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": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KP401(US)", + "next_action": { + "type": -1 + }, + "ntc_state": 0, + "oemId": "00000000000000000000000000000000", + "on_time": 127, + "relay_state": 1, + "rssi": -56, + "status": "new", + "sw_ver": "1.0.0 Build 201221 Rel.090515", + "updating": 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 new file mode 100644 index 000000000..806bdc27b --- /dev/null +++ b/tests/fixtures/iot/KP405(US)_1.0_1.0.5.json @@ -0,0 +1,65 @@ +{ + "smartlife.iot.dimmer": { + "get_dimmer_parameters": { + "bulb_type": 1, + "calibration_type": 0, + "err_code": 0, + "fadeOffTime": 1000, + "fadeOnTime": 1000, + "gentleOffTime": 10000, + "gentleOnTime": 3000, + "minThreshold": 1, + "rampRate": 30 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "brightness": 50, + "dev_name": "Kasa Smart Wi-Fi Outdoor Plug-In Dimmer", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM", + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 1, + "longitude_i": 0, + "mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KP405(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": -64, + "status": "new", + "sw_ver": "1.0.5 Build 221108 Rel.181739", + "updating": 0 + } + } +} diff --git a/tests/fixtures/iot/KP405(US)_1.0_1.0.6.json b/tests/fixtures/iot/KP405(US)_1.0_1.0.6.json new file mode 100644 index 000000000..d2431bfd5 --- /dev/null +++ b/tests/fixtures/iot/KP405(US)_1.0_1.0.6.json @@ -0,0 +1,65 @@ +{ + "smartlife.iot.dimmer": { + "get_dimmer_parameters": { + "bulb_type": 1, + "calibration_type": 0, + "err_code": 0, + "fadeOffTime": 1000, + "fadeOnTime": 1000, + "gentleOffTime": 10000, + "gentleOnTime": 3000, + "minThreshold": 1, + "rampRate": 30 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "brightness": 100, + "dev_name": "Kasa Smart Wi-Fi Outdoor Plug-In Dimmer", + "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": "50:91:E3:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KP405(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": -66, + "status": "new", + "sw_ver": "1.0.6 Build 240229 Rel.174151", + "updating": 0 + } + } +} 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 new file mode 100644 index 000000000..f9498ae90 --- /dev/null +++ b/tests/fixtures/iot/KS200M(US)_1.0_1.0.10.json @@ -0,0 +1,155 @@ +{ + "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": [ + { + "fwLocation": 0, + "fwReleaseDate": "2024-06-19", + "fwReleaseLog": "Modifications and Bug Fixes:\n1. Added \"Hold on\" feature, now you can quickly double-click the switch to keep it on without being affected by the Smart Control rules.\n2. Fixed a bug where Motion & Dark rules could still be triggered under bright light conditions.\n3. Fixed some minor bugs.", + "fwReleaseLogUrl": "undefined yet", + "fwTitle": "Hi, a new firmware with bug fixes and performance improvement is available for your KS200M.", + "fwType": 2, + "fwUrl": "http://download.tplinkcloud.com/firmware/KS200M_FCC_1.0.12_Build_240507_Rel.143458_2024-05-07_14.37.42_1718767325443.bin", + "fwVer": "1.0.12 Build 240507 Rel.143458" + } + ] + } + }, + "schedule": { + "get_next_action": { + "err_code": 0, + "type": -1 + }, + "get_rules": { + "enable": 1, + "err_code": 0, + "rule_list": [], + "version": 2 + } + }, + "smartlife.iot.LAS": { + "get_adc_value": { + "err_code": 0, + "type": 2, + "value": 0 + }, + "get_config": { + "devs": [ + { + "dark_index": 0, + "enable": 1, + "hw_id": 0, + "level_array": [ + { + "adc": 390, + "name": "cloudy", + "value": 15 + }, + { + "adc": 300, + "name": "overcast", + "value": 12 + }, + { + "adc": 222, + "name": "dawn", + "value": 9 + }, + { + "adc": 222, + "name": "twilight", + "value": 9 + }, + { + "adc": 111, + "name": "total darkness", + "value": 4 + }, + { + "adc": 2400, + "name": "custom", + "value": 97 + } + ], + "max_adc": 2450, + "min_adc": 0 + } + ], + "err_code": 0, + "ver": "1.0" + }, + "get_current_brt": { + "err_code": 0, + "value": 0 + }, + "get_dark_status": { + "bDark": 1, + "err_code": 0 + } + }, + "smartlife.iot.PIR": { + "get_adc_value": { + "err_code": 0, + "value": 2025 + }, + "get_config": { + "array": [ + 80, + 50, + 20, + 0 + ], + "cold_time": 15000, + "enable": 1, + "err_code": 0, + "max_adc": 4095, + "min_adc": 0, + "trigger_index": 2, + "version": "1.0" + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "dev_name": "Smart Light Switch with PIR", + "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": "54:AF:97:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KS200M(US)", + "next_action": { + "type": -1 + }, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "relay_state": 0, + "rssi": -38, + "status": "new", + "sw_ver": "1.0.10 Build 221019 Rel.194527", + "updating": 0 + } + } +} diff --git a/tests/fixtures/iot/KS200M(US)_1.0_1.0.11.json b/tests/fixtures/iot/KS200M(US)_1.0_1.0.11.json new file mode 100644 index 000000000..3eb480c3a --- /dev/null +++ b/tests/fixtures/iot/KS200M(US)_1.0_1.0.11.json @@ -0,0 +1,96 @@ +{ + "smartlife.iot.LAS": { + "get_config": { + "devs": [ + { + "dark_index": 0, + "enable": 0, + "hw_id": 0, + "level_array": [ + { + "adc": 390, + "name": "cloudy", + "value": 15 + }, + { + "adc": 300, + "name": "overcast", + "value": 12 + }, + { + "adc": 222, + "name": "dawn", + "value": 9 + }, + { + "adc": 222, + "name": "twilight", + "value": 9 + }, + { + "adc": 111, + "name": "total darkness", + "value": 4 + }, + { + "adc": 2400, + "name": "custom", + "value": 97 + } + ], + "max_adc": 2450, + "min_adc": 0 + } + ], + "err_code": 0, + "ver": "1.0" + } + }, + "smartlife.iot.PIR": { + "get_config": { + "array": [ + 80, + 50, + 20, + 0 + ], + "cold_time": 60000, + "enable": 0, + "err_code": 0, + "max_adc": 4095, + "min_adc": 0, + "trigger_index": 1, + "version": "1.0" + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "dev_name": "Smart Light Switch with PIR", + "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": "3C:52:A1:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KS200M(US)", + "next_action": { + "type": -1 + }, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "relay_state": 0, + "rssi": -40, + "status": "new", + "sw_ver": "1.0.11 Build 230113 Rel.151038", + "updating": 0 + } + } +} diff --git a/tests/fixtures/iot/KS200M(US)_1.0_1.0.12.json b/tests/fixtures/iot/KS200M(US)_1.0_1.0.12.json new file mode 100644 index 000000000..aabbdb06b --- /dev/null +++ b/tests/fixtures/iot/KS200M(US)_1.0_1.0.12.json @@ -0,0 +1,96 @@ +{ + "smartlife.iot.LAS": { + "get_config": { + "devs": [ + { + "dark_index": 0, + "enable": 1, + "hw_id": 0, + "level_array": [ + { + "adc": 390, + "name": "cloudy", + "value": 15 + }, + { + "adc": 300, + "name": "overcast", + "value": 11 + }, + { + "adc": 222, + "name": "dawn", + "value": 8 + }, + { + "adc": 222, + "name": "twilight", + "value": 8 + }, + { + "adc": 111, + "name": "total darkness", + "value": 4 + }, + { + "adc": 2400, + "name": "custom", + "value": 94 + } + ], + "max_adc": 2550, + "min_adc": 0 + } + ], + "err_code": 0, + "ver": "1.0" + } + }, + "smartlife.iot.PIR": { + "get_config": { + "array": [ + 80, + 50, + 20, + 0 + ], + "cold_time": 600000, + "enable": 1, + "err_code": 0, + "max_adc": 4095, + "min_adc": 0, + "trigger_index": 1, + "version": "1.0" + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "dev_name": "Smart Light Switch with PIR", + "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": "98:25:4A:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KS200M(US)", + "next_action": { + "type": -1 + }, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "relay_state": 0, + "rssi": -55, + "status": "new", + "sw_ver": "1.0.12 Build 240507 Rel.143458", + "updating": 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 new file mode 100644 index 000000000..719dab2ed --- /dev/null +++ b/tests/fixtures/iot/KS200M(US)_1.0_1.0.8.json @@ -0,0 +1,95 @@ +{ + "smartlife.iot.LAS": { + "get_config": { + "devs": [ + { + "dark_index": 4, + "enable": 1, + "hw_id": 0, + "level_array": [ + { + "adc": 390, + "name": "cloudy", + "value": 15 + }, + { + "adc": 300, + "name": "overcast", + "value": 12 + }, + { + "adc": 222, + "name": "dawn", + "value": 9 + }, + { + "adc": 220, + "name": "twilight", + "value": 8 + }, + { + "adc": 98, + "name": "total darkness", + "value": 4 + }, + { + "adc": 2400, + "name": "custom", + "value": 97 + } + ], + "max_adc": 2450, + "min_adc": 0 + } + ], + "err_code": 0, + "ver": "1.0" + } + }, + "smartlife.iot.PIR": { + "get_config": { + "array": [ + 80, + 50, + 20, + 0 + ], + "cold_time": 600000, + "enable": 1, + "err_code": 0, + "max_adc": 4095, + "min_adc": 0, + "trigger_index": 1, + "version": "1.0" + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "dev_name": "Smart Light Switch with PIR", + "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": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KS200M(US)", + "next_action": { + "type": -1 + }, + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "relay_state": 0, + "rssi": -66, + "status": "new", + "sw_ver": "1.0.8 Build 211201 Rel.125056", + "updating": 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 new file mode 100644 index 000000000..debdd722e --- /dev/null +++ b/tests/fixtures/iot/KS220(US)_1.0_1.0.13.json @@ -0,0 +1,111 @@ +{ + "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 + } + }, + "smartlife.iot.dimmer": { + "get_default_behavior": { + "double_click": { + "mode": "gentle_on_off" + }, + "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": 1, + "err_code": 0, + "fadeOffTime": 1000, + "fadeOnTime": 1000, + "gentleOffTime": 10000, + "gentleOnTime": 3000, + "minThreshold": 9, + "rampRate": 30 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "brightness": 100, + "dev_name": "Smart Wi-Fi Dimmer 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": "30:DE:4B:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KS220(US)", + "next_action": { + "type": -1 + }, + "ntc_state": 0, + "obd_src": "apple", + "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": -50, + "status": "configured", + "sw_ver": "1.0.13 Build 240424 Rel.102214", + "updating": 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 new file mode 100644 index 000000000..3dceb3222 --- /dev/null +++ b/tests/fixtures/iot/KS220M(US)_1.0_1.0.4.json @@ -0,0 +1,126 @@ +{ + "smartlife.iot.LAS": { + "get_config": { + "devs": [ + { + "dark_index": 0, + "enable": 1, + "hw_id": 0, + "level_array": [ + { + "adc": 490, + "name": "cloudy", + "value": 20 + }, + { + "adc": 294, + "name": "overcast", + "value": 12 + }, + { + "adc": 222, + "name": "dawn", + "value": 9 + }, + { + "adc": 222, + "name": "twilight", + "value": 9 + }, + { + "adc": 111, + "name": "total darkness", + "value": 4 + }, + { + "adc": 2400, + "name": "custom", + "value": 97 + } + ], + "max_adc": 2450, + "min_adc": 0 + } + ], + "err_code": 0, + "ver": "1.0" + } + }, + "smartlife.iot.PIR": { + "get_config": { + "array": [ + 80, + 50, + 20, + 61 + ], + "cold_time": 60000, + "enable": 1, + "err_code": 0, + "max_adc": 4095, + "min_adc": 0, + "trigger_index": 2, + "version": "1.0" + } + }, + "smartlife.iot.dimmer": { + "get_dimmer_parameters": { + "bulb_type": 1, + "err_code": 0, + "fadeOffTime": 0, + "fadeOnTime": 0, + "gentleOffTime": 10000, + "gentleOnTime": 3000, + "minThreshold": 1, + "rampRate": 30 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "brightness": 100, + "dev_name": "Wi-Fi Smart Dimmer with sensor", + "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": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KS220M(US)", + "next_action": { + "type": -1 + }, + "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": -42, + "status": "new", + "sw_ver": "1.0.4 Build 210616 Rel.193517", + "updating": 0 + } + } +} 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 new file mode 100644 index 000000000..8876a1af6 --- /dev/null +++ b/tests/fixtures/iot/KS230(US)_1.0_1.0.14.json @@ -0,0 +1,64 @@ +{ + "smartlife.iot.dimmer": { + "get_dimmer_parameters": { + "bulb_type": 1, + "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": 60, + "dc_state": 0, + "dev_name": "Wi-Fi Smart 3-Way Dimmer", + "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": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KS230(US)", + "next_action": { + "type": -1 + }, + "ntc_state": 0, + "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": -52, + "status": "new", + "sw_ver": "1.0.14 Build 220127 Rel.124555", + "updating": 0 + } + } +} 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/kasa/tests/fixtures/LB100(US)_1.0.json b/tests/fixtures/iot/LB110(US)_1.0_1.8.11.json similarity index 73% rename from kasa/tests/fixtures/LB100(US)_1.0.json rename to tests/fixtures/iot/LB110(US)_1.0_1.8.11.json index 8f5026e2c..8df62f234 100644 --- a/kasa/tests/fixtures/LB100(US)_1.0.json +++ b/tests/fixtures/iot/LB110(US)_1.0_1.8.11.json @@ -1,18 +1,10 @@ { - "emeter": { - "err_code": -1, - "err_msg": "module not support" - }, "smartlife.iot.common.emeter": { "get_realtime": { "err_code": 0, - "power_mw": 10800 + "power_mw": 0 } }, - "smartlife.iot.dimmer": { - "err_code": -1, - "err_msg": "module not support" - }, "smartlife.iot.smartbulb.lightingservice": { "get_light_state": { "dft_on_state": { @@ -29,25 +21,23 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Mock lb100", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" }, "description": "Smart Wi-Fi LED Bulb with Dimmable Light", "dev_state": "normal", - "deviceId": "15BD5A6C4B729A7C0D4D46ADDFA7E2600793C56A", + "deviceId": "0000000000000000000000000000000000000000", "disco_ver": "1.0", "err_code": 0, - "heapsize": 302452, - "hwId": "1B0DF0A2EFE6251DBE726D1D2167C78F", + "heapsize": 290048, + "hwId": "00000000000000000000000000000000", "hw_ver": "1.0", "is_color": 0, "is_dimmable": 1, "is_factory": false, "is_variable_color_temp": 0, - "latitude": -51.8361, - "latitude_i": -51.8361, "light_state": { "dft_on_state": { "brightness": 100, @@ -56,16 +46,12 @@ "mode": "normal", "saturation": 0 }, - "err_code": 0, "on_off": 0 }, - "longitude": -34.0697, - "longitude_i": -34.0697, - "mac": "50:c7:bf:51:10:65", + "mic_mac": "000000000000", "mic_type": "IOT.SMARTBULB", - "model": "LB100(US)", - "oemId": "C9CF655C9A5AA101E66EBA5B382E40CC", - "on_time": 0, + "model": "LB110(US)", + "oemId": "00000000000000000000000000000000", "preferred_state": [ { "brightness": 100, @@ -96,8 +82,8 @@ "saturation": 0 } ], - "rssi": -65, - "sw_ver": "1.4.3 Build 170504 Rel.144921" + "rssi": -59, + "sw_ver": "1.8.11 Build 191113 Rel.105336" } } } 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 new file mode 100644 index 000000000..40543d2d0 --- /dev/null +++ b/tests/fixtures/serialization/deviceconfig_camera-aes-https.json @@ -0,0 +1,9 @@ +{ + "connection_type": { + "device_family": "SMART.IPCAMERA", + "encryption_type": "AES", + "https": true + }, + "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 new file mode 100644 index 000000000..f78918021 --- /dev/null +++ b/tests/fixtures/serialization/deviceconfig_plug-klap.json @@ -0,0 +1,10 @@ +{ + "connection_type": { + "device_family": "SMART.TAPOPLUG", + "encryption_type": "KLAP", + "https": false, + "login_version": 2 + }, + "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 new file mode 100644 index 000000000..04e436399 --- /dev/null +++ b/tests/fixtures/serialization/deviceconfig_plug-xor.json @@ -0,0 +1,9 @@ +{ + "connection_type": { + "device_family": "IOT.SMARTPLUGSWITCH", + "encryption_type": "XOR", + "https": 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 new file mode 100644 index 000000000..e83c6221d --- /dev/null +++ b/tests/fixtures/smart/EP25(US)_2.6_1.0.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": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "homekit", + "ver_code": 2 + } + ] + }, + "discovery_result": { + "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, + "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", + "default_states": { + "state": {}, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.1 Build 230614 Rel.150219", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "2.6", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "00-00-00-00-00-00", + "model": "EP25", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 36, + "overheated": false, + "power_protection_status": "normal", + "region": "America/Chicago", + "rssi": -51, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -360, + "type": "SMART.KASAPLUG" + }, + "get_device_time": { + "region": "America/Chicago", + "time_diff": -360, + "timestamp": 1704406778 + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 38793, + "past7": 9619, + "today": 979 + }, + "time_usage": { + "past30": 38793, + "past7": 9619, + "today": 979 + } + }, + "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_energy_usage": { + "current_power": 0, + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2024-01-04 16:19:38", + "month_energy": 0, + "month_runtime": 5299, + "today_energy": 0, + "today_runtime": 979 + }, + "get_fw_download_state": { + "auto_upgrade": true, + "download_progress": 100, + "reboot_time": 5, + "status": 3, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 786432, + "fw_ver": "1.0.2 Build 231108 Rel.163012", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2023-12-05", + "release_note": "Modifications and Bug Fixes:\n1. Enhanced device security.\n2. Optimized Homekit setup.\n3. Improved time synchronization accuracy.\n4. Fixed some minor bugs.", + "type": 1 + }, + "get_led_info": { + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 438, + "night_mode_type": "sunrise_sunset", + "start_time": 1056, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_max_power": { + "max_power": 1883 + }, + "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": "wpa_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "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": "EP25", + "device_type": "SMART.KASAPLUG" + } + } +} 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 new file mode 100644 index 000000000..4aebbe0e7 --- /dev/null +++ b/tests/fixtures/smart/EP25(US)_2.6_1.0.2.json @@ -0,0 +1,417 @@ +{ + "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": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "homekit", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "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, + "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", + "default_states": { + "state": {}, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.2 Build 231108 Rel.163012", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "2.6", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "3C-52-A1-00-00-00", + "model": "EP25", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 155838, + "overcurrent_status": "normal", + "overheated": false, + "power_protection_status": "normal", + "region": "America/Chicago", + "rssi": -56, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -360, + "type": "SMART.KASAPLUG" + }, + "get_device_time": { + "region": "America/Chicago", + "time_diff": -360, + "timestamp": 1705991903 + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 41789, + "past7": 8678, + "today": 38 + }, + "time_usage": { + "past30": 41789, + "past7": 8678, + "today": 38 + } + }, + "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_energy_usage": { + "current_power": 0, + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2024-01-23 00:38:23", + "month_energy": 0, + "month_runtime": 31709, + "today_energy": 0, + "today_runtime": 38 + }, + "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.2 Build 231108 Rel.163012", + "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": 436, + "night_mode_type": "sunrise_sunset", + "start_time": 1072, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_max_power": { + "max_power": 1885 + }, + "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": [], + "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": "EP25", + "device_type": "SMART.KASAPLUG", + "is_klap": false + } + } +} 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 new file mode 100644 index 000000000..9eef29dc7 --- /dev/null +++ b/tests/fixtures/smart/EP40M(US)_1.0_1.1.0.json @@ -0,0 +1,879 @@ +{ + "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": "overheat_protection", + "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": "tape_lights", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.0 Build 240415 Rel.171219", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "F0090D000000", + "model": "EP40M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 2, + "region": "America/Denver", + "slot_number": 2, + "status_follow_edge": true, + "type": "SMART.KASAPLUG" + }, + "get_device_usage": { + "time_usage": { + "past30": 2, + "past7": 2, + "today": 0 + } + }, + "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": "overheat_protection", + "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": "house", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.0 Build 240415 Rel.171219", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "F0090D000000", + "model": "EP40M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 1, + "region": "America/Denver", + "slot_number": 2, + "status_follow_edge": true, + "type": "SMART.KASAPLUG" + }, + "get_device_usage": { + "time_usage": { + "past30": 842, + "past7": 842, + "today": 249 + } + }, + "get_next_event": { + "desired_states": { + "on": true + }, + "e_time": 0, + "id": "S1", + "s_time": 1729382340, + "type": 1 + }, + "get_schedule_rules": { + "enable": true, + "rule_list": [ + { + "day": 19, + "desired_states": { + "on": true + }, + "e_action": "none", + "e_min": 0, + "e_type": "normal", + "enable": true, + "id": "S1", + "mode": "repeat", + "month": 10, + "s_min": 1081, + "s_type": "sunset", + "time_offset": -15, + "week_day": 127, + "year": 2024 + }, + { + "day": 19, + "desired_states": { + "on": false + }, + "e_action": "none", + "e_min": 0, + "e_type": "normal", + "enable": true, + "id": "S2", + "mode": "repeat", + "month": 10, + "s_min": 1330, + "s_type": "normal", + "time_offset": 0, + "week_day": 127, + "year": 2024 + } + ], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 2 + } + } + }, + "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": "matter", + "ver_code": 2 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "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, + "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": "overheat_protection", + "ver_code": 1 + } + ], + "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": "overheat_protection", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_child_device_list": { + "child_device_list": [ + { + "avatar": "tape_lights", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.0 Build 240415 Rel.171219", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "F0090D000000", + "model": "EP40M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 2, + "region": "America/Denver", + "slot_number": 2, + "status_follow_edge": true, + "type": "SMART.KASAPLUG" + }, + { + "avatar": "house", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.0 Build 240415 Rel.171219", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "F0090D000000", + "model": "EP40M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 1, + "region": "America/Denver", + "slot_number": 2, + "status_follow_edge": true, + "type": "SMART.KASAPLUG" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "avatar": "", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.0 Build 240415 Rel.171219", + "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": "F0-09-0D-00-00-00", + "model": "EP40M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "region": "America/Denver", + "rssi": -66, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -420, + "type": "SMART.KASAPLUG" + }, + "get_device_time": { + "region": "America/Denver", + "time_diff": -420, + "timestamp": 1729316541 + }, + "get_device_usage": { + "time_usage": { + "past30": 842, + "past7": 842, + "today": 249 + } + }, + "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.0 Build 240415 Rel.171219", + "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": 435, + "night_mode_type": "sunrise_sunset", + "start_time": 1096, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:000000-000000000000" + }, + "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": 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": 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==" + } + ], + "start_index": 0, + "sum": 18, + "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": "EP40M", + "device_type": "SMART.KASAPLUG", + "is_klap": 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 new file mode 100644 index 000000000..ba09016a3 --- /dev/null +++ b/tests/fixtures/smart/H100(EU)_1.0_1.2.3.json @@ -0,0 +1,224 @@ +{ + "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 + } + ] + }, + "discovery_result": { + "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, + "random_range": 120, + "time": 180 + }, + "get_child_device_component_list": { + "child_component_list": [], + "start_index": 0, + "sum": 0 + }, + "get_child_device_list": { + "child_device_list": [], + "start_index": 0, + "sum": 0 + }, + "get_connect_cloud_state": { + "status": 1 + }, + "get_device_info": { + "avatar": "", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.2.3 Build 221012 Rel.103821", + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "in_alarm": false, + "in_alarm_source": "", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "longitude": 0, + "mac": "3C-52-A1-00-00-00", + "model": "H100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "", + "rssi": -30, + "signal_level": 3, + "specs": "EU", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 0, + "type": "SMART.TAPOHUB" + }, + "get_device_time": { + "region": "", + "time_diff": 0, + "timestamp": 946771480 + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_led_info": { + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 420, + "night_mode_type": "sunrise_sunset", + "start_time": 1140, + "sunrise_offset": 0, + "sunset_offset": 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==" + } + ], + "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": "H100", + "device_type": "SMART.TAPOHUB" + } + } +} 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 new file mode 100644 index 000000000..4e0e5258f --- /dev/null +++ b/tests/fixtures/smart/H100(EU)_1.0_1.5.10.json @@ -0,0 +1,568 @@ +{ + "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": 1 + }, + { + "id": "matter", + "ver_code": 3 + }, + { + "id": "chime", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "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, + "type": "Alarm 1", + "volume": "high" + }, + "get_auto_update_info": { + "enable": false, + "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": "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": "frost_protection", + "ver_code": 1 + }, + { + "id": "child_protection", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "temp_control", + "ver_code": 1 + }, + { + "id": "remove_scale", + "ver_code": 1 + }, + { + "id": "progress_calibration", + "ver_code": 1 + }, + { + "id": "early_start", + "ver_code": 1 + }, + { + "id": "temp_record", + "ver_code": 1 + }, + { + "id": "screen_setting", + "ver_code": 1 + }, + { + "id": "night_mode", + "ver_code": 1 + }, + { + "id": "smart_control_schedule", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature_correction", + "ver_code": 1 + }, + { + "id": "window_open_detect", + "ver_code": 2 + }, + { + "id": "shutdown_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": "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_2" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_child_device_list": { + "child_device_list": [ + { + "at_low_battery": false, + "avatar": "", + "battery_percentage": 100, + "bind_count": 5, + "category": "subg.trv", + "child_protection": false, + "current_temp": 22.9, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "frost_protection_on": false, + "fw_ver": "2.4.0 Build 230804 Rel.193040", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -113, + "jamming_signal_level": 1, + "location": "", + "mac": "A842A1000000", + "max_control_temp": 30, + "min_control_temp": 5, + "model": "KE100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/Berlin", + "rssi": -7, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "target_temp": 23.0, + "temp_offset": 0, + "temp_unit": "celsius", + "trv_states": [ + "heating" + ], + "type": "SMART.KASAENERGY" + }, + { + "at_low_battery": false, + "avatar": "", + "battery_percentage": 100, + "bind_count": 1, + "category": "subg.trigger.temp-hmdt-sensor", + "current_humidity": 62, + "current_humidity_exception": 2, + "current_temp": 24.0, + "current_temp_exception": 0, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "fw_ver": "1.7.0 Build 230424 Rel.170332", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -115, + "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": -38, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "temp_unit": "celsius", + "type": "SMART.TAPOSENSOR" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "avatar": "hub", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.5.10 Build 240207 Rel.175759", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "in_alarm": false, + "in_alarm_source": "", + "ip": "127.0.0.123", + "lang": "de_DE", + "latitude": 0, + "longitude": 0, + "mac": "3C-52-A1-00-00-00", + "model": "H100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/Berlin", + "rssi": -60, + "signal_level": 2, + "specs": "EU", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 60, + "type": "SMART.TAPOHUB" + }, + "get_device_load_info": { + "cur_load_num": 4, + "load_level": "light", + "max_load_num": 64, + "total_memory": 4352, + "used_memory": 1451 + }, + "get_device_time": { + "region": "Europe/Berlin", + "time_diff": 60, + "timestamp": 1714669215 + }, + "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.10 Build 240207 Rel.175759", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "led_rule": "never", + "led_status": false, + "night_mode": { + "end_time": 358, + "night_mode_type": "sunrise_sunset", + "start_time": 1259, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_matter_setup_info": { + "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", + "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": [], + "start_index": 0, + "sum": 0, + "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": false + } + } +} 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 new file mode 100644 index 000000000..fadb35d25 --- /dev/null +++ b/tests/fixtures/smart/H100(EU)_1.0_1.5.5.json @@ -0,0 +1,394 @@ +{ + "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": 1 + }, + { + "id": "matter", + "ver_code": 3 + } + ] + }, + "discovery_result": { + "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, + "type": "Doorbell Ring 2", + "volume": "low" + }, + "get_auto_update_info": { + "enable": false, + "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" + } + ], + "start_index": 0, + "sum": 1 + }, + "get_child_device_list": { + "child_device_list": [ + { + "at_low_battery": false, + "avatar": "", + "battery_percentage": 100, + "bind_count": 1, + "category": "subg.trigger.temp-hmdt-sensor", + "current_humidity": 56, + "current_humidity_exception": -34, + "current_temp": 22.2, + "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": -118, + "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": -45, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "temp_unit": "celsius", + "type": "SMART.TAPOSENSOR" + } + ], + "start_index": 0, + "sum": 1 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "avatar": "hub", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.5.5 Build 240105 Rel.192438", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "in_alarm": false, + "in_alarm_source": "", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "longitude": 0, + "mac": "3C-52-A1-00-00-00", + "model": "H100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/Berlin", + "rssi": -62, + "signal_level": 2, + "specs": "EU", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 60, + "type": "SMART.TAPOHUB" + }, + "get_device_load_info": { + "cur_load_num": 2, + "load_level": "light", + "max_load_num": 64, + "total_memory": 4352, + "used_memory": 1384 + }, + "get_device_time": { + "region": "Europe/Berlin", + "time_diff": 60, + "timestamp": 1706995844 + }, + "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.5 Build 240105 Rel.192438", + "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": 485, + "night_mode_type": "sunrise_sunset", + "start_time": 1046, + "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": [], + "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": false + } + } +} 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 new file mode 100644 index 000000000..f17269cc9 --- /dev/null +++ b/tests/fixtures/smart/HS200(US)_5.26_1.0.3.json @@ -0,0 +1,382 @@ +{ + "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": 3 + }, + { + "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": "delay_action", + "ver_code": 2 + }, + { + "id": "smart_switch", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "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, + "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": "hang_lamp_1", + "default_states": { + "state": { + "on": false + }, + "type": "custom" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.3 Build 240723 Rel.192622", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "5.26", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "74-FE-CE-00-00-00", + "model": "HS200", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheat_status": "normal", + "region": "America/New_York", + "rssi": -56, + "signal_level": 2, + "smart_switch_state": false, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -300, + "type": "SMART.KASASWITCH" + }, + "get_device_time": { + "region": "America/New_York", + "time_diff": -300, + "timestamp": 1732300703 + }, + "get_device_usage": { + "time_usage": { + "past30": 185, + "past7": 185, + "today": 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.0.3 Build 240723 Rel.192622", + "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": "toggle", + "led_status": true, + "night_mode": { + "end_time": 420, + "night_mode_type": "custom", + "start_time": 1320 + } + }, + "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": 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": 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": 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": 17, + "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": "HS200", + "device_type": "SMART.KASASWITCH", + "is_klap": true + } + } +} 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 new file mode 100644 index 000000000..998189846 --- /dev/null +++ b/tests/fixtures/smart/HS220(US)_3.26_1.0.1.json @@ -0,0 +1,374 @@ +{ + "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": 3 + }, + { + "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": "smart_switch", + "ver_code": 1 + }, + { + "id": "dimmer_custom_action", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "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": { + "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": "table_lamp_5", + "brightness": 51, + "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.1 Build 230829 Rel.160220", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "3.26", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "24-2F-D0-00-00-00", + "model": "HS220", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheat_status": "normal", + "region": "America/Detroit", + "rssi": -62, + "signal_level": 2, + "smart_switch_state": false, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -300, + "type": "SMART.KASASWITCH" + }, + "get_device_time": { + "region": "America/Detroit", + "time_diff": -300, + "timestamp": 1720201570 + }, + "get_device_usage": { + "time_usage": { + "past30": 30, + "past7": 30, + "today": 11 + } + }, + "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.1 Build 230829 Rel.160220", + "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": "toggle", + "led_status": true, + "night_mode": { + "end_time": 420, + "night_mode_type": "custom", + "start_time": 1320 + } + }, + "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": 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==" + } + ], + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "HS220", + "device_type": "SMART.KASASWITCH", + "is_klap": false + } + } +} 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 new file mode 100644 index 000000000..0f24be148 --- /dev/null +++ b/tests/fixtures/smart/KH100(EU)_1.0_1.2.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": "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 + } + ] + }, + "discovery_result": { + "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, + "type": "Doorbell Ring 1", + "volume": "high" + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_child_device_component_list": { + "child_component_list": [], + "start_index": 0, + "sum": 0 + }, + "get_child_device_list": { + "child_device_list": [], + "start_index": 0, + "sum": 0 + }, + "get_connect_cloud_state": { + "status": 1 + }, + "get_device_info": { + "avatar": "", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.2.3 Build 221012 Rel.103821", + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "in_alarm": false, + "in_alarm_source": "", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "longitude": 0, + "mac": "A8-42-A1-00-00-00", + "model": "KH100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "", + "rssi": -61, + "signal_level": 2, + "specs": "EU", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 0, + "type": "SMART.KASAHUB" + }, + "get_device_load_info": { + "cur_load_num": 0, + "load_level": "light", + "max_load_num": 64, + "total_memory": 4352, + "used_memory": 250 + }, + "get_device_time": { + "region": "", + "time_diff": 0, + "timestamp": 1725109066 + }, + "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_led_info": { + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 420, + "night_mode_type": "sunrise_sunset", + "start_time": 1140, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "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": [], + "start_index": 0, + "sum": 0, + "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": "KH100", + "device_type": "SMART.KASAHUB" + } + } +} 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 new file mode 100644 index 000000000..53684a580 --- /dev/null +++ b/tests/fixtures/smart/KH100(EU)_1.0_1.5.12.json @@ -0,0 +1,432 @@ +{ + "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": 1 + } + ] + }, + "discovery_result": { + "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, + "type": "Doorbell Ring 1", + "volume": "high" + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 900 + }, + "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": "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": "frost_protection", + "ver_code": 1 + }, + { + "id": "child_protection", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "temp_control", + "ver_code": 1 + }, + { + "id": "remove_scale", + "ver_code": 1 + }, + { + "id": "progress_calibration", + "ver_code": 1 + }, + { + "id": "early_start", + "ver_code": 1 + }, + { + "id": "temp_record", + "ver_code": 1 + }, + { + "id": "screen_setting", + "ver_code": 1 + }, + { + "id": "night_mode", + "ver_code": 1 + }, + { + "id": "smart_control_schedule", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature_correction", + "ver_code": 1 + }, + { + "id": "window_open_detect", + "ver_code": 2 + }, + { + "id": "shutdown_mode", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" + } + ], + "start_index": 0, + "sum": 1 + }, + "get_child_device_list": { + "child_device_list": [ + { + "at_low_battery": false, + "avatar": "", + "battery_percentage": 75, + "bind_count": 7, + "category": "subg.trv", + "child_protection": false, + "current_temp": 24.0, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "frost_protection_on": false, + "fw_ver": "2.4.0 Build 230804 Rel.193040", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -116, + "jamming_signal_level": 1, + "location": "", + "mac": "A842A1000000", + "max_control_temp": 30, + "min_control_temp": 5, + "model": "KE100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/Berlin", + "rssi": -13, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "target_temp": 22.0, + "temp_offset": 0, + "temp_unit": "celsius", + "trv_states": [], + "type": "SMART.KASAENERGY" + } + ], + "start_index": 0, + "sum": 1 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "avatar": "kasa_hub", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.5.12 Build 240320 Rel.123648", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "in_alarm": false, + "in_alarm_source": "", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "longitude": 0, + "mac": "A8-42-A1-00-00-00", + "model": "KH100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/Berlin", + "rssi": -60, + "signal_level": 2, + "specs": "EU", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 60, + "type": "SMART.KASAHUB" + }, + "get_device_load_info": { + "cur_load_num": 2, + "load_level": "light", + "max_load_num": 64, + "total_memory": 4352, + "used_memory": 1439 + }, + "get_device_time": { + "region": "Europe/Berlin", + "time_diff": 60, + "timestamp": 1725111902 + }, + "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.12 Build 240320 Rel.123648", + "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": 406, + "night_mode_type": "sunrise_sunset", + "start_time": 1217, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "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==" + } + ], + "start_index": 0, + "sum": 1, + "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": "KH100", + "device_type": "SMART.KASAHUB", + "is_klap": true + } + } +} 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 new file mode 100644 index 000000000..c0eeb89b1 --- /dev/null +++ b/tests/fixtures/smart/KH100(UK)_1.0_1.5.6.json @@ -0,0 +1,1560 @@ +{ + "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": 1 + } + ] + }, + "discovery_result": { + "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, + "type": "Doorbell Ring 1", + "volume": "high" + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 900 + }, + "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": "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_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": "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": "frost_protection", + "ver_code": 1 + }, + { + "id": "child_protection", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "temp_control", + "ver_code": 1 + }, + { + "id": "remove_scale", + "ver_code": 1 + }, + { + "id": "shutdown_mode", + "ver_code": 1 + }, + { + "id": "progress_calibration", + "ver_code": 1 + }, + { + "id": "early_start", + "ver_code": 1 + }, + { + "id": "temp_record", + "ver_code": 1 + }, + { + "id": "screen_setting", + "ver_code": 1 + }, + { + "id": "night_mode", + "ver_code": 1 + }, + { + "id": "smart_control_schedule", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature_correction", + "ver_code": 1 + }, + { + "id": "window_open_detect", + "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": "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": "frost_protection", + "ver_code": 1 + }, + { + "id": "child_protection", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "temp_control", + "ver_code": 1 + }, + { + "id": "remove_scale", + "ver_code": 1 + }, + { + "id": "shutdown_mode", + "ver_code": 1 + }, + { + "id": "progress_calibration", + "ver_code": 1 + }, + { + "id": "early_start", + "ver_code": 1 + }, + { + "id": "temp_record", + "ver_code": 1 + }, + { + "id": "screen_setting", + "ver_code": 1 + }, + { + "id": "night_mode", + "ver_code": 1 + }, + { + "id": "smart_control_schedule", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature_correction", + "ver_code": 1 + }, + { + "id": "window_open_detect", + "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": "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": "frost_protection", + "ver_code": 1 + }, + { + "id": "child_protection", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "temp_control", + "ver_code": 1 + }, + { + "id": "remove_scale", + "ver_code": 1 + }, + { + "id": "shutdown_mode", + "ver_code": 1 + }, + { + "id": "progress_calibration", + "ver_code": 1 + }, + { + "id": "early_start", + "ver_code": 1 + }, + { + "id": "temp_record", + "ver_code": 1 + }, + { + "id": "screen_setting", + "ver_code": 1 + }, + { + "id": "night_mode", + "ver_code": 1 + }, + { + "id": "smart_control_schedule", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature_correction", + "ver_code": 1 + }, + { + "id": "window_open_detect", + "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": "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": "frost_protection", + "ver_code": 1 + }, + { + "id": "child_protection", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "temp_control", + "ver_code": 1 + }, + { + "id": "remove_scale", + "ver_code": 1 + }, + { + "id": "shutdown_mode", + "ver_code": 1 + }, + { + "id": "progress_calibration", + "ver_code": 1 + }, + { + "id": "early_start", + "ver_code": 1 + }, + { + "id": "temp_record", + "ver_code": 1 + }, + { + "id": "screen_setting", + "ver_code": 1 + }, + { + "id": "night_mode", + "ver_code": 1 + }, + { + "id": "smart_control_schedule", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature_correction", + "ver_code": 1 + }, + { + "id": "window_open_detect", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_6" + }, + { + "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": "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": "frost_protection", + "ver_code": 1 + }, + { + "id": "child_protection", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "temp_control", + "ver_code": 1 + }, + { + "id": "remove_scale", + "ver_code": 1 + }, + { + "id": "shutdown_mode", + "ver_code": 1 + }, + { + "id": "progress_calibration", + "ver_code": 1 + }, + { + "id": "early_start", + "ver_code": 1 + }, + { + "id": "temp_record", + "ver_code": 1 + }, + { + "id": "screen_setting", + "ver_code": 1 + }, + { + "id": "night_mode", + "ver_code": 1 + }, + { + "id": "smart_control_schedule", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature_correction", + "ver_code": 1 + }, + { + "id": "window_open_detect", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_7" + }, + { + "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_8" + }, + { + "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_9" + }, + { + "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_10" + } + ], + "start_index": 0, + "sum": 10 + }, + "get_child_device_list": { + "child_device_list": [ + { + "at_low_battery": false, + "avatar": "kasa_trv", + "bind_count": 1, + "category": "subg.trv", + "child_protection": false, + "current_temp": 20.1, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3", + "frost_protection_on": false, + "fw_ver": "2.8.0 Build 240202 Rel.135229", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -117, + "jamming_signal_level": 1, + "location": "", + "mac": "F0A731000000", + "max_control_temp": 30, + "min_control_temp": 5, + "model": "KE100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/London", + "rssi": -45, + "signal_level": 3, + "specs": "UK", + "status": "online", + "status_follow_edge": false, + "target_temp": 21.0, + "temp_offset": 0, + "temp_unit": "celsius", + "trv_states": [ + "heating" + ], + "type": "SMART.KASAENERGY" + }, + { + "at_low_battery": false, + "avatar": "kasa_trv", + "bind_count": 1, + "category": "subg.trv", + "child_protection": false, + "current_temp": 19.5, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_4", + "frost_protection_on": false, + "fw_ver": "2.8.0 Build 240202 Rel.135229", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -117, + "jamming_signal_level": 1, + "location": "", + "mac": "F0A731000000", + "max_control_temp": 30, + "min_control_temp": 5, + "model": "KE100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/London", + "rssi": -52, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "target_temp": 21.0, + "temp_offset": 0, + "temp_unit": "celsius", + "trv_states": [ + "heating" + ], + "type": "SMART.KASAENERGY" + }, + { + "at_low_battery": false, + "avatar": "kasa_trv", + "bind_count": 1, + "category": "subg.trv", + "child_protection": false, + "current_temp": 18.1, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_5", + "frost_protection_on": false, + "fw_ver": "2.8.0 Build 240202 Rel.135229", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -120, + "jamming_signal_level": 1, + "location": "", + "mac": "F0A731000000", + "max_control_temp": 30, + "min_control_temp": 5, + "model": "KE100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/London", + "rssi": -46, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "target_temp": 21.0, + "temp_offset": 0, + "temp_unit": "celsius", + "trv_states": [ + "heating" + ], + "type": "SMART.KASAENERGY" + }, + { + "at_low_battery": false, + "avatar": "kasa_trv", + "bind_count": 1, + "category": "subg.trv", + "child_protection": false, + "current_temp": 20.0, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_6", + "frost_protection_on": false, + "fw_ver": "2.8.0 Build 240202 Rel.135229", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -114, + "jamming_signal_level": 1, + "location": "", + "mac": "A842A1000000", + "max_control_temp": 30, + "min_control_temp": 5, + "model": "KE100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/London", + "rssi": -40, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "target_temp": 21.0, + "temp_offset": 0, + "temp_unit": "celsius", + "trv_states": [ + "heating" + ], + "type": "SMART.KASAENERGY" + }, + { + "at_low_battery": false, + "avatar": "kasa_trv", + "bind_count": 1, + "category": "subg.trv", + "child_protection": false, + "current_temp": 19.2, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_7", + "frost_protection_on": false, + "fw_ver": "2.8.0 Build 240202 Rel.135229", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -121, + "jamming_signal_level": 1, + "location": "", + "mac": "F0A731000000", + "max_control_temp": 30, + "min_control_temp": 5, + "model": "KE100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/London", + "rssi": -73, + "signal_level": 2, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "target_temp": 21.0, + "temp_offset": 0, + "temp_unit": "celsius", + "trv_states": [ + "heating" + ], + "type": "SMART.KASAENERGY" + }, + { + "at_low_battery": false, + "avatar": "balcony", + "bind_count": 1, + "category": "subg.trigger.temp-hmdt-sensor", + "current_humidity": 63, + "current_humidity_exception": 3, + "current_temp": 11.9, + "current_temp_exception": -8.1, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "fw_ver": "1.5.0 Build 230105 Rel.150707", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -118, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1713199738, + "mac": "40AE30000000", + "model": "T310", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/London", + "report_interval": 16, + "rssi": -63, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "temp_unit": "celsius", + "type": "SMART.TAPOSENSOR" + }, + { + "at_low_battery": false, + "avatar": "sensor_t310", + "bind_count": 1, + "category": "subg.trigger.temp-hmdt-sensor", + "current_humidity": 54, + "current_humidity_exception": 0, + "current_temp": 19.1, + "current_temp_exception": -0.9, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "fw_ver": "1.5.0 Build 230105 Rel.150707", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -123, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1712755472, + "mac": "40AE30000000", + "model": "T310", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/London", + "report_interval": 16, + "rssi": -57, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "temp_unit": "celsius", + "type": "SMART.TAPOSENSOR" + }, + { + "at_low_battery": false, + "avatar": "sensor_t310", + "bind_count": 1, + "category": "subg.trigger.temp-hmdt-sensor", + "current_humidity": 52, + "current_humidity_exception": 0, + "current_temp": 20.0, + "current_temp_exception": 0, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_8", + "fw_ver": "1.5.0 Build 230105 Rel.150707", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -119, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1706550338, + "mac": "E4FAC4000000", + "model": "T310", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/London", + "report_interval": 16, + "rssi": -68, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "temp_unit": "celsius", + "type": "SMART.TAPOSENSOR" + }, + { + "at_low_battery": false, + "avatar": "sensor_t310", + "bind_count": 1, + "category": "subg.trigger.temp-hmdt-sensor", + "current_humidity": 60, + "current_humidity_exception": 0, + "current_temp": 20.1, + "current_temp_exception": 0.1, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_9", + "fw_ver": "1.5.0 Build 230105 Rel.150707", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -116, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1706551426, + "mac": "E4FAC4000000", + "model": "T310", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/London", + "report_interval": 16, + "rssi": -70, + "signal_level": 2, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "temp_unit": "celsius", + "type": "SMART.TAPOSENSOR" + }, + { + "at_low_battery": false, + "avatar": "sensor_t310", + "bind_count": 1, + "category": "subg.trigger.temp-hmdt-sensor", + "current_humidity": 54, + "current_humidity_exception": 0, + "current_temp": 19.3, + "current_temp_exception": 0, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_10", + "fw_ver": "1.5.0 Build 230105 Rel.150707", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -120, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1706789728, + "mac": "E4FAC4000000", + "model": "T310", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/London", + "report_interval": 16, + "rssi": -81, + "signal_level": 1, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "temp_unit": "celsius", + "type": "SMART.TAPOSENSOR" + } + ], + "start_index": 0, + "sum": 10 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "avatar": "kasa_hub", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.5.6 Build 240202 Rel.164142", + "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": "F0-A7-31-00-00-00", + "model": "KH100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/London", + "rssi": -37, + "signal_level": 3, + "specs": "UK", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 0, + "type": "SMART.KASAHUB" + }, + "get_device_load_info": { + "cur_load_num": 24, + "load_level": "light", + "max_load_num": 64, + "total_memory": 4352, + "used_memory": 1581 + }, + "get_device_time": { + "region": "Europe/London", + "time_diff": 0, + "timestamp": 1713550228 + }, + "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.6 Build 240202 Rel.164142", + "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": 358, + "night_mode_type": "sunrise_sunset", + "start_time": 1210, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "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": [], + "start_index": 0, + "sum": 0, + "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": "KH100", + "device_type": "SMART.KASAHUB", + "is_klap": false + } + } +} 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 new file mode 100644 index 000000000..41a34cb33 --- /dev/null +++ b/tests/fixtures/smart/KP125M(US)_1.0_1.1.3.json @@ -0,0 +1,178 @@ +{ + "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": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "matter", + "ver_code": 2 + } + ] + }, + "discovery_result": { + "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 + }, + "get_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "coffee_maker", + "default_states": { + "state": {}, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.3 Build 230801 Rel.092557", + "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": "00-00-00-00-00-00", + "model": "KP125M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 5332, + "overheated": false, + "power_protection_status": "normal", + "region": "America/Chicago", + "rssi": -62, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -360, + "type": "SMART.KASAPLUG" + }, + "get_device_time": { + "region": "America/Chicago", + "time_diff": -360, + "timestamp": 1701569348 + }, + "get_device_usage": { + "power_usage": { + "past30": 6757, + "past7": 4101, + "today": 513 + }, + "saved_power": { + "past30": 9516, + "past7": 5748, + "today": 696 + }, + "time_usage": { + "past30": 16273, + "past7": 9849, + "today": 1209 + } + }, + "get_energy_usage": { + "current_power": 17654, + "electricity_charge": [ + 0, + 64, + 111 + ], + "local_time": "2023-12-02 20:09:08", + "month_energy": 1020, + "month_runtime": 2649, + "today_energy": 513, + "today_runtime": 1209 + } +} 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 new file mode 100644 index 000000000..9878b65b7 --- /dev/null +++ b/tests/fixtures/smart/KP125M(US)_1.0_1.2.3.json @@ -0,0 +1,536 @@ +{ + "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": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "matter", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "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, + "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": 1 + }, + "get_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "egg_boiler", + "default_states": { + "state": {}, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.2.3 Build 240624 Rel.154806", + "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": "78-8C-B5-00-00-00", + "model": "KP125M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 936394, + "overcurrent_status": "normal", + "overheat_status": "normal", + "power_protection_status": "normal", + "region": "America/Denver", + "rssi": -50, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -420, + "type": "SMART.KASAPLUG" + }, + "get_device_time": { + "region": "America/Denver", + "time_diff": -420, + "timestamp": 1732069933 + }, + "get_device_usage": { + "power_usage": { + "past30": 971, + "past7": 442, + "today": 20 + }, + "saved_power": { + "past30": 14896, + "past7": 9370, + "today": 1152 + }, + "time_usage": { + "past30": 15867, + "past7": 9812, + "today": 1172 + } + }, + "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": 33, + "energy_wh": 971, + "power_mw": 1003, + "voltage_mv": 121215 + }, + "get_emeter_vgain_igain": { + "igain": 10861, + "vgain": 118657 + }, + "get_energy_usage": { + "current_power": 1003, + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2024-11-19 19:32:14", + "month_energy": 971, + "month_runtime": 15867, + "today_energy": 20, + "today_runtime": 1172 + }, + "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.3 Build 240624 Rel.154806", + "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_max_power": { + "max_power": 1542 + }, + "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": 3, + "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==" + } + ], + "start_index": 0, + "sum": 12, + "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": "KP125M", + "device_type": "SMART.KASAPLUG", + "is_klap": true + } + } +} 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 new file mode 100644 index 000000000..60611f333 --- /dev/null +++ b/tests/fixtures/smart/KS205(US)_1.0_1.0.2.json @@ -0,0 +1,246 @@ +{ + "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": "matter", + "ver_code": 2 + }, + { + "id": "delay_action", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "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, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "get_auto_update_info": { + "enable": false, + "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": {}, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.2 Build 230810 Rel.140202", + "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": "00-00-00-00-00-00", + "model": "KS205", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheated": false, + "region": "America/Toronto", + "rssi": -49, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -300, + "type": "SMART.KASASWITCH" + }, + "get_device_time": { + "region": "America/Toronto", + "time_diff": -300, + "timestamp": 1704216652 + }, + "get_device_usage": { + "time_usage": { + "past30": 10908, + "past7": 3476, + "today": 10 + } + }, + "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.2 Build 230810 Rel.140202", + "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": 476, + "night_mode_type": "sunrise_sunset", + "start_time": 1020, + "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": [], + "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": "KS205", + "device_type": "SMART.KASASWITCH", + "is_klap": false + } + } +} 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 new file mode 100644 index 000000000..9f7419ec5 --- /dev/null +++ b/tests/fixtures/smart/KS205(US)_1.0_1.1.0.json @@ -0,0 +1,301 @@ +{ + "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": "matter", + "ver_code": 2 + } + ] + }, + "discovery_result": { + "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, + "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": {}, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.0 Build 240411 Rel.144632", + "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": "40-ED-00-00-00-00", + "model": "KS205", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheat_status": "normal", + "region": "America/Toronto", + "rssi": -57, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -300, + "type": "SMART.KASASWITCH" + }, + "get_device_time": { + "region": "America/Toronto", + "time_diff": -300, + "timestamp": 1720146765 + }, + "get_device_usage": { + "time_usage": { + "past30": 10601, + "past7": 966, + "today": 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.0 Build 240411 Rel.144632", + "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": 351, + "night_mode_type": "sunrise_sunset", + "start_time": 1266, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:0000000-0000.00.000" + }, + "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": 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": 1, + "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": 6, + "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": "KS205", + "device_type": "SMART.KASASWITCH", + "is_klap": true + } + } +} 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 new file mode 100644 index 000000000..1f2d9d2bc --- /dev/null +++ b/tests/fixtures/smart/KS225(US)_1.0_1.0.2.json @@ -0,0 +1,272 @@ +{ + "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": "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, + "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": 34, + "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.2 Build 230810 Rel.141013", + "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": "00-00-00-00-00-00", + "model": "KS225", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheat_status": "normal", + "region": "America/Toronto", + "rssi": -51, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -300, + "type": "SMART.KASASWITCH" + }, + "get_device_time": { + "region": "America/Toronto", + "time_diff": -300, + "timestamp": 1704216678 + }, + "get_device_usage": { + "time_usage": { + "past30": 7410, + "past7": 2190, + "today": 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.2 Build 230810 Rel.141013", + "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": 476, + "night_mode_type": "sunrise_sunset", + "start_time": 1020, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_next_event": {}, + "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": [], + "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": false + } + } +} 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 new file mode 100644 index 000000000..61ead9294 --- /dev/null +++ b/tests/fixtures/smart/KS225(US)_1.0_1.1.0.json @@ -0,0 +1,335 @@ +{ + "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": "tplink", + "owner": "" + } + }, + "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": 5, + "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.1.0 Build 240411 Rel.150716", + "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": 88, + "overheat_status": "normal", + "region": "America/Toronto", + "rssi": -48, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -300, + "type": "SMART.KASASWITCH" + }, + "get_device_time": { + "region": "America/Toronto", + "time_diff": -300, + "timestamp": 1720036002 + }, + "get_device_usage": { + "time_usage": { + "past30": 1371, + "past7": 659, + "today": 58 + } + }, + "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.0 Build 240411 Rel.150716", + "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": 350, + "night_mode_type": "sunrise_sunset", + "start_time": 1266, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "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": 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": 1, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "start_index": 0, + "sum": 5, + "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/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 new file mode 100644 index 000000000..15092b858 --- /dev/null +++ b/tests/fixtures/smart/KS240(US)_1.0_1.0.4.json @@ -0,0 +1,902 @@ +{ + "child_devices": { + "SCRUBBED_CHILD_DEVICE_ID_1": { + "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": "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": "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 + } + ] + }, + "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_ks240", + "bind_count": 1, + "brightness": 44, + "category": "kasa.switch.outlet.sub-dimmer", + "default_states": { + "re_power_type": "always_off", + "re_power_type_capability": [ + "last_states", + "always_on", + "always_off" + ], + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_on": true, + "fade_off_time": 5, + "fade_on_time": 5, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.4 Build 230721 Rel.184322", + "gradually_off_mode": 0, + "gradually_on_mode": 0, + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "lang": "en_US", + "latitude": 0, + "led_off": 0, + "longitude": 0, + "mac": "F0A731000000", + "max_fade_off_time": 60, + "max_fade_on_time": 60, + "model": "KS240", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_off": 1, + "on_time": 67955, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "preset_state": [ + { + "brightness": 100 + }, + { + "brightness": 75 + }, + { + "brightness": 50 + }, + { + "brightness": 25 + }, + { + "brightness": 1 + } + ], + "region": "America/Chicago", + "specs": "", + "status_follow_edge": true, + "type": "SMART.KASASWITCH" + }, + "get_device_usage": { + "time_usage": { + "past30": 41994, + "past7": 8874, + "today": 236 + } + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "off_state": { + "duration": 5, + "enable": false, + "max_duration": 60 + }, + "on_state": { + "duration": 5, + "enable": false, + "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 + } + }, + "SCRUBBED_CHILD_DEVICE_ID_2": { + "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": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "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": "device_local_time", + "ver_code": 1 + }, + { + "id": "fan_control", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "switch_ks240", + "bind_count": 1, + "category": "kasa.switch.outlet.sub-fan", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "device_on": true, + "fan_sleep_mode_on": false, + "fan_speed_level": 4, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.4 Build 230721 Rel.184322", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "F0A731000000", + "model": "KS240", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "region": "America/Chicago", + "specs": "", + "status_follow_edge": true, + "type": "SMART.KASASWITCH" + }, + "get_device_usage": { + "time_usage": { + "past30": 6786, + "past7": 6786, + "today": 236 + } + }, + "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": 2 + }, + { + "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": "brightness", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 1 + }, + { + "id": "on_off_gradually", + "ver_code": 2 + }, + { + "id": "dimmer_calibration", + "ver_code": 1 + }, + { + "id": "fan_control", + "ver_code": 1 + }, + { + "id": "homekit", + "ver_code": 2 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "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, + "random_range": 120, + "time": 180 + }, + "get_child_device_component_list": { + "child_component_list": [ + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "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": "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 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "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": "device_local_time", + "ver_code": 1 + }, + { + "id": "fan_control", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_child_device_list": { + "child_device_list": [ + { + "avatar": "switch_ks240", + "bind_count": 1, + "category": "kasa.switch.outlet.sub-fan", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "device_on": true, + "fan_sleep_mode_on": false, + "fan_speed_level": 4, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.4 Build 230721 Rel.184322", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "F0A731000000", + "model": "KS240", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "region": "America/Chicago", + "specs": "", + "status_follow_edge": true, + "type": "SMART.KASASWITCH" + }, + { + "avatar": "switch_ks240", + "bind_count": 1, + "brightness": 44, + "category": "kasa.switch.outlet.sub-dimmer", + "default_states": { + "re_power_type": "always_off", + "re_power_type_capability": [ + "last_states", + "always_on", + "always_off" + ], + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_on": true, + "fade_off_time": 5, + "fade_on_time": 5, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.4 Build 230721 Rel.184322", + "gradually_off_mode": 0, + "gradually_on_mode": 0, + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "lang": "en_US", + "latitude": 0, + "led_off": 0, + "longitude": 0, + "mac": "F0A731000000", + "max_fade_off_time": 60, + "max_fade_on_time": 60, + "model": "KS240", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_off": 1, + "on_time": 67951, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "preset_state": [ + { + "brightness": 100 + }, + { + "brightness": 75 + }, + { + "brightness": 50 + }, + { + "brightness": 25 + }, + { + "brightness": 1 + } + ], + "region": "America/Chicago", + "specs": "", + "status_follow_edge": true, + "type": "SMART.KASASWITCH" + } + ], + "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.4 Build 230721 Rel.184322", + "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": "F0-A7-31-00-00-00", + "model": "KS240", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "region": "America/Chicago", + "rssi": -37, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -360, + "type": "SMART.KASASWITCH" + }, + "get_device_time": { + "region": "America/Chicago", + "time_diff": -360, + "timestamp": 1714553757 + }, + "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": "0000000000000000000000000000000000/00000000000000000000000/00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000+00000000000000000=", + "mfi_token_uuid": "00000000-0000-0000-0000-000000000000" + }, + "get_latest_fw": { + "fw_size": 786432, + "fw_ver": "1.0.5 Build 231204 Rel.172150", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2024-01-12", + "release_note": "Modifications and Bug Fixes:\n1. Improved time synchronization accuracy.\n2. Enhanced stability and performance.\n3. Fixed some minor bugs.", + "type": 2 + }, + "get_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "led_rule": "auto", + "led_status": false, + "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": 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==" + } + ], + "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 + }, + { + "id": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "KS240", + "device_type": "SMART.KASASWITCH" + } + } +} 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 new file mode 100644 index 000000000..fb6c667dd --- /dev/null +++ b/tests/fixtures/smart/KS240(US)_1.0_1.0.5.json @@ -0,0 +1,482 @@ +{ + "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": 2 + }, + { + "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": "brightness", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 1 + }, + { + "id": "on_off_gradually", + "ver_code": 2 + }, + { + "id": "dimmer_calibration", + "ver_code": 1 + }, + { + "id": "fan_control", + "ver_code": 1 + }, + { + "id": "homekit", + "ver_code": 2 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "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, + "random_range": 120, + "time": 180 + }, + "get_child_device_component_list": { + "child_component_list": [ + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "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": "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 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "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": "device_local_time", + "ver_code": 1 + }, + { + "id": "fan_control", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_child_device_list": { + "child_device_list": [ + { + "avatar": "switch_ks240", + "bind_count": 1, + "category": "kasa.switch.outlet.sub-fan", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "device_on": true, + "fan_sleep_mode_on": false, + "fan_speed_level": 1, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 231204 Rel.172150", + "gc": 1, + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "la": 0, + "lang": "", + "lo": 0, + "mac": "F0A731000000", + "model": "KS240", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "region": "America/New_York", + "specs": "", + "status_follow_edge": true, + "type": "SMART.KASASWITCH" + }, + { + "avatar": "switch_ks240", + "bind_count": 1, + "brightness": 100, + "category": "kasa.switch.outlet.sub-dimmer", + "default_states": { + "re_power_type": "always_off", + "re_power_type_capability": [ + "last_states", + "always_on", + "always_off" + ], + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_on": false, + "fade_off_time": 1, + "fade_on_time": 1, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 231204 Rel.172150", + "gc": 1, + "gradually_off_mode": 1, + "gradually_on_mode": 1, + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "la": 0, + "lang": "", + "led_off": 0, + "lo": 0, + "mac": "F0A731000000", + "max_fade_off_time": 60, + "max_fade_on_time": 60, + "model": "KS240", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_off": 0, + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "preset_state": [ + { + "brightness": 100 + }, + { + "brightness": 75 + }, + { + "brightness": 50 + }, + { + "brightness": 25 + }, + { + "brightness": 1 + } + ], + "region": "America/New_York", + "specs": "", + "status_follow_edge": true, + "type": "SMART.KASASWITCH" + } + ], + "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.5 Build 231204 Rel.172150", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "longitude": 0, + "mac": "F0-A7-31-00-00-00", + "model": "KS240", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "region": "America/New_York", + "rssi": -46, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -300, + "type": "SMART.KASASWITCH" + }, + "get_device_time": { + "region": "America/New_York", + "time_diff": -300, + "timestamp": 1707863232 + }, + "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 231204 Rel.172150", + "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": "auto", + "led_status": true, + "night_mode": { + "end_time": 420, + "night_mode_type": "custom", + "start_time": 1320 + } + }, + "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": "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": "KS240", + "device_type": "SMART.KASASWITCH", + "is_klap": false + } + } +} 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 new file mode 100644 index 000000000..4630a977c --- /dev/null +++ b/tests/fixtures/smart/KS240(US)_1.0_1.0.7.json @@ -0,0 +1,929 @@ +{ + "child_devices": { + "SCRUBBED_CHILD_DEVICE_ID_1": { + "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": "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": "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 + } + ] + }, + "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_ks240", + "bind_count": 1, + "brightness": 100, + "category": "kasa.switch.outlet.sub-dimmer", + "default_states": { + "re_power_type": "always_off", + "re_power_type_capability": [ + "last_states", + "always_on", + "always_off" + ], + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_on": false, + "fade_off_time": 1, + "fade_on_time": 1, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.7 Build 240607 Rel.205559", + "gc": 1, + "gradually_off_mode": 1, + "gradually_on_mode": 1, + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "la": 0, + "lang": "en_US", + "led_off": 0, + "lo": 0, + "mac": "F0A731000000", + "max_fade_off_time": 60, + "max_fade_on_time": 60, + "model": "KS240", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_off": 0, + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "preset_state": [ + { + "brightness": 100 + }, + { + "brightness": 75 + }, + { + "brightness": 50 + }, + { + "brightness": 25 + }, + { + "brightness": 1 + } + ], + "region": "America/Denver", + "specs": "", + "status_follow_edge": true, + "type": "SMART.KASASWITCH" + }, + "get_device_usage": { + "time_usage": { + "past30": 113, + "past7": 113, + "today": 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": { + "brightness": [ + 100, + 75, + 50, + 25, + 1 + ] + }, + "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": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "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": "device_local_time", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "fan_control", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "ceiling_fan", + "bind_count": 1, + "category": "kasa.switch.outlet.sub-fan", + "default_states": { + "re_power_fan_speed": 1, + "re_power_type": "last_states", + "re_power_type_capability": [ + "last_states", + "always_on", + "always_off" + ] + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "device_on": false, + "fan_sleep_mode_on": false, + "fan_speed_level": 3, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.7 Build 240607 Rel.205559", + "gc": 1, + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "la": 0, + "lang": "en_US", + "lo": 0, + "mac": "F0A731000000", + "model": "KS240", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "region": "America/Denver", + "specs": "", + "status_follow_edge": true, + "type": "SMART.KASASWITCH" + }, + "get_device_usage": { + "time_usage": { + "past30": 0, + "past7": 0, + "today": 0 + } + }, + "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": 2 + }, + { + "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": "brightness", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 1 + }, + { + "id": "on_off_gradually", + "ver_code": 2 + }, + { + "id": "dimmer_calibration", + "ver_code": 1 + }, + { + "id": "fan_control", + "ver_code": 1 + }, + { + "id": "homekit", + "ver_code": 2 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "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, + "random_range": 120, + "time": 180 + }, + "get_child_device_component_list": { + "child_component_list": [ + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "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": "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 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" + }, + { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "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": "device_local_time", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "fan_control", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_child_device_list": { + "child_device_list": [ + { + "avatar": "ceiling_fan", + "bind_count": 1, + "category": "kasa.switch.outlet.sub-fan", + "default_states": { + "re_power_fan_speed": 1, + "re_power_type": "last_states", + "re_power_type_capability": [ + "last_states", + "always_on", + "always_off" + ] + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "device_on": false, + "fan_sleep_mode_on": false, + "fan_speed_level": 3, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.7 Build 240607 Rel.205559", + "gc": 1, + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "la": 0, + "lang": "en_US", + "lo": 0, + "mac": "F0A731000000", + "model": "KS240", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "region": "America/Denver", + "specs": "", + "status_follow_edge": true, + "type": "SMART.KASASWITCH" + }, + { + "avatar": "switch_ks240", + "bind_count": 1, + "brightness": 100, + "category": "kasa.switch.outlet.sub-dimmer", + "default_states": { + "re_power_type": "always_off", + "re_power_type_capability": [ + "last_states", + "always_on", + "always_off" + ], + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_on": false, + "fade_off_time": 1, + "fade_on_time": 1, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.7 Build 240607 Rel.205559", + "gc": 1, + "gradually_off_mode": 1, + "gradually_on_mode": 1, + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "la": 0, + "lang": "en_US", + "led_off": 0, + "lo": 0, + "mac": "F0A731000000", + "max_fade_off_time": 60, + "max_fade_on_time": 60, + "model": "KS240", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_off": 0, + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "preset_state": [ + { + "brightness": 100 + }, + { + "brightness": 75 + }, + { + "brightness": 50 + }, + { + "brightness": 25 + }, + { + "brightness": 1 + } + ], + "region": "America/Denver", + "specs": "", + "status_follow_edge": true, + "type": "SMART.KASASWITCH" + } + ], + "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.7 Build 240607 Rel.205559", + "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": "F0-A7-31-00-00-00", + "model": "KS240", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "region": "America/Denver", + "rssi": -50, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -420, + "type": "SMART.KASASWITCH" + }, + "get_device_time": { + "region": "America/Denver", + "time_diff": -420, + "timestamp": 1730957728 + }, + "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": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000+00000000000000000000000000000000000000/00000000000000+0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000/00000000000", + "mfi_token_uuid": "00000000-0000-0000-0000-000000000000" + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.0.7 Build 240607 Rel.205559", + "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": "auto", + "led_status": true, + "night_mode": { + "end_time": 420, + "night_mode_type": "custom", + "start_time": 1200 + } + }, + "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": 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==" + } + ], + "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 + }, + { + "id": "control_child", + "ver_code": 2 + }, + { + "id": "child_device", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "KS240", + "device_type": "SMART.KASASWITCH", + "is_klap": 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 new file mode 100644 index 000000000..f89dfc698 --- /dev/null +++ b/tests/fixtures/smart/L510B(EU)_3.0_1.0.5.json @@ -0,0 +1,266 @@ +{ + "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": "brightness", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "bulb_quick_control", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "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, + "enable": false, + "rule_list": [] + }, + "get_auto_update_info": { + "enable": false, + "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_range": [ + 2700, + 2700 + ], + "default_states": { + "brightness": { + "type": "last_states", + "value": 100 + }, + "re_power_type": "always_on" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 230529 Rel.113426", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "3.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "5C-E9-31-00-00-00", + "model": "L510", + "music_rhythm_enable": false, + "music_rhythm_mode": "single_lamp", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/London", + "rssi": -72, + "signal_level": 1, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 0, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Europe/London", + "time_diff": 0, + "timestamp": 1705744435 + }, + "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": null, + "get_latest_fw": { + "fw_size": 786432, + "fw_ver": "1.1.2 Build 231120 Rel.201048", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2023-12-04", + "release_note": "Modifications and Bug Fixes:\n1. Added the support for setting the Fade In time manually.\n2. Optimized Wi-Fi connection stability\n3. Enhanced local communication security.\n4. Fixed some minor bugs.", + "type": 1 + }, + "get_next_event": {}, + "get_preset_rules": { + "brightness": [ + 1, + 25, + 50, + 75, + 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": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "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": "L510", + "device_type": "SMART.TAPOBULB" + } + } +} 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 new file mode 100644 index 000000000..a81222e4c --- /dev/null +++ b/tests/fixtures/smart/L510E(US)_3.0_1.0.5.json @@ -0,0 +1,306 @@ +{ + "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": "brightness", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "bulb_quick_control", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "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, + "enable": false, + "rule_list": [] + }, + "get_auto_update_info": { + "enable": false, + "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_range": [ + 2700, + 2700 + ], + "default_states": { + "brightness": { + "type": "last_states", + "value": 100 + }, + "re_power_type": "always_on" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 230529 Rel.113426", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "3.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "3C-52-A1-00-00-00", + "model": "L510", + "music_rhythm_enable": false, + "music_rhythm_mode": "single_lamp", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Pacific/Honolulu", + "rssi": -65, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -600, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Pacific/Honolulu", + "time_diff": -600, + "timestamp": 1706121405 + }, + "get_device_usage": { + "power_usage": { + "past30": 5, + "past7": 5, + "today": 5 + }, + "saved_power": { + "past30": 26, + "past7": 26, + "today": 26 + }, + "time_usage": { + "past30": 31, + "past7": 31, + "today": 31 + } + }, + "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": 786432, + "fw_ver": "1.1.2 Build 231120 Rel.201048", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2023-12-04", + "release_note": "Modifications and Bug Fixes:\n1. Added the support for setting the Fade In time manually.\n2. Optimized Wi-Fi connection stability\n3. Enhanced local communication security.\n4. Fixed some minor bugs.", + "type": 1 + }, + "get_next_event": {}, + "get_preset_rules": { + "brightness": [ + 1, + 25, + 50, + 75, + 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": 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==" + } + ], + "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": "L510", + "device_type": "SMART.TAPOBULB" + } + } +} 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 new file mode 100644 index 000000000..523d49925 --- /dev/null +++ b/tests/fixtures/smart/L510E(US)_3.0_1.1.2.json @@ -0,0 +1,270 @@ +{ + "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": "brightness", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "bulb_quick_control", + "ver_code": 1 + }, + { + "id": "on_off_gradually", + "ver_code": 3 + }, + { + "id": "localSmart", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "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, + "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": "bulb", + "brightness": 100, + "color_temp_range": [ + 2700, + 2700 + ], + "default_states": { + "brightness": { + "type": "last_states", + "value": 100 + }, + "re_power_type": "always_on" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.2 Build 231120 Rel.201048", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "3.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "3C-52-A1-00-00-00", + "model": "L510", + "music_rhythm_enable": false, + "music_rhythm_mode": "single_lamp", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Pacific/Honolulu", + "rssi": -69, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -600, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Pacific/Honolulu", + "time_diff": -600, + "timestamp": 1706060527 + }, + "get_device_usage": { + "power_usage": { + "past30": 1, + "past7": 1, + "today": 1 + }, + "saved_power": { + "past30": 9, + "past7": 9, + "today": 9 + }, + "time_usage": { + "past30": 10, + "past7": 10, + "today": 10 + } + }, + "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.2 Build 231120 Rel.201048", + "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": { + "brightness": [ + 1, + 25, + 50, + 75, + 100 + ] + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "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 + } + ], + "extra_info": { + "device_model": "L510", + "device_type": "SMART.TAPOBULB", + "is_klap": true + } + } +} 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 new file mode 100644 index 000000000..05c04522f --- /dev/null +++ b/tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json @@ -0,0 +1,428 @@ +{ + "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": 1 + }, + { + "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 + } + ] + }, + "discovery_result": { + "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, + "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": 1 + }, + "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": 9, + "saturation": 67 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "dynamic_light_effect_enable": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.6 Build 230509 Rel.195312", + "has_set_location_info": true, + "hue": 9, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "3.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "5C-E9-31-00-00-00", + "model": "L530", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/Berlin", + "rssi": -48, + "saturation": 67, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 60, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Europe/Berlin", + "time_diff": 60, + "timestamp": 1705700000 + }, + "get_device_usage": { + "power_usage": { + "past30": 19, + "past7": 3, + "today": 3 + }, + "saved_power": { + "past30": 179, + "past7": 20, + "today": 20 + }, + "time_usage": { + "past30": 198, + "past7": 23, + "today": 23 + } + }, + "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_next_event": {}, + "get_on_off_gradually_info": { + "enable": false + }, + "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": [], + "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" + } + } +} 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 new file mode 100644 index 000000000..a32c0463d --- /dev/null +++ b/tests/fixtures/smart/L530E(EU)_3.0_1.1.0.json @@ -0,0 +1,443 @@ +{ + "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": 1 + }, + { + "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(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, + "enable": false, + "rule_list": [] + }, + "get_auto_light_info": { + "enable": false, + "mode": "light_track" + }, + "get_auto_update_info": { + "enable": false, + "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": 2700, + "color_temp_range": [ + 2500, + 6500 + ], + "default_states": { + "re_power_type": "last_states", + "state": { + "brightness": 100, + "color_temp": 2700, + "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.0 Build 230823 Rel.163903", + "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": "5C-E9-31-00-00-00", + "model": "L530", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/London", + "rssi": -79, + "saturation": 100, + "signal_level": 1, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 0, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Europe/London", + "time_diff": 0, + "timestamp": 1705742727 + }, + "get_device_usage": { + "power_usage": { + "past30": 140, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 1198, + "past7": 0, + "today": 0 + }, + "time_usage": { + "past30": 1338, + "past7": 0, + "today": 0 + } + }, + "get_dynamic_light_effect_rules": { + "enable": false, + "max_count": 2, + "rule_list": [ + { + "change_mode": "direct", + "change_time": 3000, + "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": "cGFydHky" + }, + { + "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.0 Build 230823 Rel.163903", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "enable": false + }, + "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": [], + "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.1.6.json b/tests/fixtures/smart/L530E(EU)_3.0_1.1.6.json new file mode 100644 index 000000000..8da76d78b --- /dev/null +++ b/tests/fixtures/smart/L530E(EU)_3.0_1.1.6.json @@ -0,0 +1,452 @@ +{ + "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(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, + "enable": false, + "rule_list": [] + }, + "get_auto_light_info": { + "enable": false, + "mode": "light_track" + }, + "get_auto_update_info": { + "enable": false, + "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": 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.1.6 Build 240130 Rel.173828", + "has_set_location_info": true, + "hue": 0, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "3.0", + "ip": "127.0.0.123", + "lang": "de_DE", + "latitude": 0, + "longitude": 0, + "mac": "5C-E9-31-00-00-00", + "model": "L530", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/Berlin", + "rssi": -52, + "saturation": 100, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 60, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Europe/Berlin", + "time_diff": 60, + "timestamp": 1708652415 + }, + "get_device_usage": { + "power_usage": { + "past30": 21, + "past7": 21, + "today": 21 + }, + "saved_power": { + "past30": 100, + "past7": 99, + "today": 99 + }, + "time_usage": { + "past30": 121, + "past7": 120, + "today": 120 + } + }, + "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.6 Build 240130 Rel.173828", + "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": true, + "max_duration": 60 + }, + "on_state": { + "duration": 2, + "enable": true, + "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": [], + "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(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 new file mode 100644 index 000000000..0c80d3a52 --- /dev/null +++ b/tests/fixtures/smart/L530E(US)_2.0_1.1.0.json @@ -0,0 +1,442 @@ +{ + "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": 1 + }, + { + "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(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, + "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": 0, + "color_temp_range": [ + 2500, + 6500 + ], + "default_states": { + "re_power_type": "always_on", + "state": { + "brightness": 100, + "color_temp": 0, + "hue": 12, + "saturation": 45 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "dynamic_light_effect_enable": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.0 Build 230721 Rel.224802", + "has_set_location_info": true, + "hue": 12, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "2.0", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "longitude": 0, + "mac": "5C-62-8B-00-00-00", + "model": "L530", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Pacific/Honolulu", + "rssi": -41, + "saturation": 45, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -600, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Pacific/Honolulu", + "time_diff": -600, + "timestamp": 1705991895 + }, + "get_device_usage": { + "power_usage": { + "past30": 2, + "past7": 2, + "today": 2 + }, + "saved_power": { + "past30": 8, + "past7": 8, + "today": 8 + }, + "time_usage": { + "past30": 10, + "past7": 10, + "today": 10 + } + }, + "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.0 Build 230721 Rel.224802", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "enable": false + }, + "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": [], + "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" + } + } +} 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 new file mode 100644 index 000000000..3fb263be7 --- /dev/null +++ b/tests/fixtures/smart/L630(EU)_1.0_1.1.2.json @@ -0,0 +1,455 @@ +{ + "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": "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, + "enable": false, + "rule_list": [] + }, + "get_auto_light_info": { + "enable": false, + "mode": "light_track" + }, + "get_auto_update_info": { + "enable": false, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 1 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [ + { + "delay": 120, + "desired_states": { + "on": false + }, + "enable": false, + "id": "C1", + "remain": 0 + } + ] + }, + "get_device_info": { + "avatar": "", + "brightness": 35, + "color_temp": 3200, + "color_temp_range": [ + 2200, + 6500 + ], + "default_states": { + "re_power_type": "always_on", + "state": { + "brightness": 100, + "color_temp": 3100, + "hue": 0, + "saturation": 100 + }, + "type": "custom" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "dynamic_light_effect_enable": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.2 Build 240926 Rel.164744", + "has_set_location_info": false, + "hue": 0, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "40-AE-30-00-00-00", + "model": "L630", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/Vienna", + "rssi": -71, + "saturation": 100, + "signal_level": 1, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 60, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Europe/Vienna", + "time_diff": 60, + "timestamp": 1731224827 + }, + "get_device_usage": { + "power_usage": { + "past30": 206, + "past7": 11, + "today": 0 + }, + "saved_power": { + "past30": 6610, + "past7": 424, + "today": 0 + }, + "time_usage": { + "past30": 6816, + "past7": 435, + "today": 0 + } + }, + "get_dynamic_light_effect_rules": { + "enable": false, + "max_count": 2, + "rule_list": [ + { + "change_mode": "direct", + "change_time": 1000, + "color_status_list": [ + [ + 10, + 0, + 0, + 2700 + ], + [ + 10, + 321, + 99, + 0 + ], + [ + 10, + 196, + 99, + 0 + ], + [ + 10, + 6, + 97, + 0 + ], + [ + 10, + 160, + 100, + 0 + ], + [ + 10, + 274, + 95, + 0 + ], + [ + 10, + 48, + 100, + 0 + ], + [ + 10, + 242, + 99, + 0 + ] + ], + "id": "L1", + "scene_name": "Party" + }, + { + "change_mode": "bln", + "change_time": 3000, + "color_status_list": [ + [ + 100, + 240, + 100, + 0 + ], + [ + 100, + 195, + 100, + 0 + ], + [ + 100, + 195, + 100, + 0 + ], + [ + 100, + 240, + 100, + 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_next_event": {}, + "get_on_off_gradually_info": { + "off_state": { + "duration": 4, + "enable": false, + "max_duration": 60 + }, + "on_state": { + "duration": 8, + "enable": true, + "max_duration": 60 + } + }, + "get_preset_rules": { + "states": [ + { + "brightness": 100, + "color_temp": 3100, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 4500, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 50, + "color_temp": 3200, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 1, + "color_temp": 3200, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 1, + "color_temp": 0, + "hue": 300, + "saturation": 100 + }, + { + "brightness": 1, + "color_temp": 0, + "hue": 195, + "saturation": 100 + }, + { + "brightness": 1, + "color_temp": 0, + "hue": 240, + "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": 1, + "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": "L630", + "device_type": "SMART.TAPOBULB", + "is_klap": true + } + } +} 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 new file mode 100644 index 000000000..816cf8964 --- /dev/null +++ b/tests/fixtures/smart/L900-10(EU)_1.0_1.0.17.json @@ -0,0 +1,444 @@ +{ + "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": "bulb_quick_control", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "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, + "enable": false, + "rule_list": [] + }, + "get_auto_update_info": { + "enable": false, + "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": [ + 9000, + 9000 + ], + "default_states": { + "state": { + "brightness": 100, + "color_temp": 0, + "hue": 230, + "saturation": 96 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.17 Build 230426 Rel.153230", + "has_set_location_info": true, + "hue": 230, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "lighting_effect": { + "brightness": 100, + "custom": 0, + "display_colors": [ + [ + 0, + 100, + 100 + ], + [ + 30, + 95, + 100 + ], + [ + 0, + 0, + 100 + ] + ], + "enable": 0, + "id": "TapoStrip_5NiN0Y8GAUD78p4neKk9EL", + "name": "Sunset" + }, + "longitude": 0, + "mac": "F0-A7-31-00-00-00", + "model": "L900", + "music_rhythm_enable": false, + "music_rhythm_mode": "single_lamp", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/London", + "rssi": -60, + "saturation": 96, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 0, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Europe/London", + "time_diff": 0, + "timestamp": 1705742728 + }, + "get_device_usage": { + "power_usage": { + "past30": 94, + "past7": 79, + "today": 0 + }, + "saved_power": { + "past30": 434, + "past7": 365, + "today": 0 + }, + "time_usage": { + "past30": 528, + "past7": 444, + "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": 786432, + "fw_ver": "1.1.0 Build 230905 Rel.184939", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2023-10-13", + "release_note": "Modifications and Bug Fixes:\n1. Enhanced stability and performance.\n2. Enhanced the local communication security.", + "type": 2 + }, + "get_lighting_effect": { + "brightness": 100, + "custom": 0, + "direction": 1, + "display_colors": [ + [ + 0, + 100, + 100 + ], + [ + 30, + 95, + 100 + ], + [ + 0, + 0, + 100 + ] + ], + "duration": 600, + "enable": 0, + "expansion_strategy": 2, + "id": "TapoStrip_5NiN0Y8GAUD78p4neKk9EL", + "name": "Sunset", + "repeat_times": 1, + "run_time": 0, + "segments": [ + 0 + ], + "sequence": [ + [ + 30, + 0, + 100 + ], + [ + 30, + 20, + 100 + ], + [ + 30, + 50, + 99 + ], + [ + 30, + 60, + 98 + ], + [ + 30, + 70, + 97 + ], + [ + 30, + 75, + 95 + ], + [ + 30, + 80, + 93 + ], + [ + 30, + 90, + 90 + ], + [ + 30, + 95, + 85 + ], + [ + 30, + 100, + 80 + ], + [ + 20, + 100, + 70 + ], + [ + 20, + 100, + 60 + ], + [ + 15, + 100, + 50 + ], + [ + 10, + 100, + 40 + ], + [ + 0, + 100, + 30 + ], + [ + 0, + 100, + 0 + ] + ], + "spread": 1, + "trans_sequence": [], + "transition": 60000, + "type": "pulse" + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "enable": false + }, + "get_preset_rules": { + "start_index": 0, + "states": [ + { + "brightness": 50, + "color_temp": 9000, + "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 + } + ], + "sum": 7 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 16, + "start_index": 0, + "sum": 0 + }, + "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 + } + ], + "extra_info": { + "device_model": "L900", + "device_type": "SMART.TAPOBULB" + } + } +} 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 new file mode 100644 index 000000000..5c81fd322 --- /dev/null +++ b/tests/fixtures/smart/L900-10(US)_1.0_1.0.11.json @@ -0,0 +1,431 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "light_strip", + "ver_code": 1 + }, + { + "id": "light_strip_lighting_effect", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "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": 2 + } + ] + }, + "discovery_result": { + "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, + "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": "light_strip", + "brightness": 100, + "color_temp": 9000, + "color_temp_range": [ + 9000, + 9000 + ], + "default_states": { + "state": { + "brightness": 100, + "color_temp": 9000, + "hue": 0, + "saturation": 100 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.11 Build 220119 Rel.221258", + "has_set_location_info": true, + "hue": 0, + "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": "54-AF-97-00-00-00", + "model": "L900", + "music_rhythm_enable": false, + "music_rhythm_mode": "single_lamp", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Pacific/Honolulu", + "rssi": -42, + "saturation": 100, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -600, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Pacific/Honolulu", + "time_diff": -600, + "timestamp": 1706141011 + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 3, + "past7": 3, + "today": 3 + }, + "time_usage": { + "past30": 3, + "past7": 3, + "today": 3 + } + }, + "get_fw_download_state": { + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_latest_fw": { + "fw_size": 786432, + "fw_ver": "1.1.0 Build 230905 Rel.184939", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2023-10-13", + "release_note": "Modifications and Bug Fixes:\n1. Enhanced stability and performance.\n2. Enhanced the local communication security.", + "type": 2 + }, + "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": 9000, + "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 + } + ], + "sum": 7 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 16, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "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 + } + ], + "extra_info": { + "device_model": "L900", + "device_type": "SMART.TAPOBULB" + } + } +} 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 new file mode 100644 index 000000000..7c7ac420c --- /dev/null +++ b/tests/fixtures/smart/L900-5(EU)_1.0_1.0.17.json @@ -0,0 +1,387 @@ +{ + "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": "bulb_quick_control", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "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, + "enable": false, + "rule_list": [] + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 1 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "", + "brightness": 100, + "color_temp": 0, + "color_temp_range": [ + 9000, + 9000 + ], + "default_states": { + "state": { + "brightness": 100, + "color_temp": 0, + "hue": 354, + "saturation": 93 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.17 Build 230426 Rel.153230", + "has_set_location_info": false, + "hue": 354, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "lighting_effect": { + "brightness": 50, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "station" + }, + "longitude": 0, + "mac": "A8-42-A1-00-00-00", + "model": "L900", + "music_rhythm_enable": false, + "music_rhythm_mode": "single_lamp", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/Berlin", + "rssi": -55, + "saturation": 93, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 0, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Europe/Berlin", + "time_diff": 0, + "timestamp": 1705699997 + }, + "get_device_usage": { + "power_usage": { + "past30": 15, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 81, + "past7": 1, + "today": 0 + }, + "time_usage": { + "past30": 96, + "past7": 1, + "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_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": 9000, + "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 + } + ], + "sum": 7 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 16, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "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": "L900", + "device_type": "SMART.TAPOBULB" + } + } +} 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 new file mode 100644 index 000000000..98980a4c8 --- /dev/null +++ b/tests/fixtures/smart/L900-5(EU)_1.0_1.1.0.json @@ -0,0 +1,393 @@ +{ + "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": "bulb_quick_control", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "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, + "enable": false, + "rule_list": [] + }, + "get_auto_update_info": { + "enable": false, + "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": 9000, + "color_temp_range": [ + 9000, + 9000 + ], + "default_states": { + "state": { + "brightness": 100, + "color_temp": 9000, + "hue": 0, + "saturation": 100 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.0 Build 230905 Rel.184939", + "has_set_location_info": false, + "hue": 0, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "lighting_effect": { + "brightness": 50, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "station" + }, + "longitude": 0, + "mac": "A8-42-A1-00-00-00", + "model": "L900", + "music_rhythm_enable": false, + "music_rhythm_mode": "single_lamp", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "", + "rssi": -50, + "saturation": 100, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 60, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "", + "time_diff": 60, + "timestamp": 1705796222 + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 2, + "past7": 2, + "today": 2 + }, + "time_usage": { + "past30": 2, + "past7": 2, + "today": 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.0 Build 230905 Rel.184939", + "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": 9000, + "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 + } + ], + "sum": 7 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 16, + "start_index": 0, + "sum": 0 + }, + "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 + } + ], + "extra_info": { + "device_model": "L900", + "device_type": "SMART.TAPOBULB", + "is_klap": true + } + } +} 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 new file mode 100644 index 000000000..3315b19b6 --- /dev/null +++ b/tests/fixtures/smart/L920-5(EU)_1.0_1.0.7.json @@ -0,0 +1,353 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "light_strip", + "ver_code": 1 + }, + { + "id": "light_strip_lighting_effect", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "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": 2 + }, + { + "id": "segment", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "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, + "enable": false, + "rule_list": [] + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 1 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "", + "brightness": 100, + "color_temp": 9000, + "color_temp_range": [ + 9000, + 9000 + ], + "default_states": { + "state": { + "brightness": 100, + "color_temp": 9000, + "hue": 0, + "saturation": 100 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.7 Build 220119 Rel.221439", + "has_set_location_info": false, + "hue": 0, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "lighting_effect": { + "brightness": 0, + "custom": 0, + "display_colors": [], + "enable": 1, + "id": "", + "name": "softAP" + }, + "longitude": 0, + "mac": "1C-61-B4-00-00-00", + "model": "L920", + "music_rhythm_enable": false, + "music_rhythm_mode": "single_lamp", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "", + "rssi": -46, + "saturation": 100, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 0, + "type": "SMART.TAPOBULB" + }, + "get_device_segment": { + "segment": 50 + }, + "get_device_time": { + "region": "", + "time_diff": 0, + "timestamp": 946771372 + }, + "get_fw_download_state": { + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_lighting_effect": { + "brightness": 0, + "custom": 0, + "direction": 1, + "display_colors": [], + "duration": 0, + "enable": 1, + "expansion_strategy": 0, + "id": "", + "name": "softAP", + "repeat_times": 0, + "segment_length": 1, + "sequence": [ + [ + 30, + 100, + 0 + ], + [ + 30, + 100, + 50 + ], + [ + 30, + 100, + 0 + ], + [ + 120, + 100, + 0 + ], + [ + 120, + 100, + 50 + ], + [ + 120, + 100, + 0 + ] + ], + "spread": 8, + "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": 9000, + "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": 24, + "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==" + } + ], + "start_index": 0, + "sum": 1, + "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 + } + ], + "extra_info": { + "device_model": "L920", + "device_type": "SMART.TAPOBULB" + } + } +} 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 new file mode 100644 index 000000000..0f845bf3c --- /dev/null +++ b/tests/fixtures/smart/L920-5(EU)_1.0_1.1.3.json @@ -0,0 +1,1088 @@ +{ + "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": "bulb_quick_control", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "segment", + "ver_code": 1 + }, + { + "id": "segment_effect", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "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, + "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": "light_strip", + "brightness": 100, + "color_temp": 0, + "color_temp_range": [ + 9000, + 9000 + ], + "default_states": { + "state": { + "brightness": 100, + "color_temp": 0, + "hue": 30, + "saturation": 0 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.3 Build 231229 Rel.164316", + "has_set_location_info": true, + "hue": 30, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "lighting_effect": { + "brightness": 100, + "custom": 0, + "display_colors": [ + [ + 30, + 0, + 100 + ], + [ + 30, + 95, + 100 + ], + [ + 0, + 100, + 100 + ] + ], + "enable": 0, + "id": "TapoStrip_1OVSyXIsDxrt4j7OxyRvqi", + "name": "Sunrise" + }, + "longitude": 0, + "mac": "B4-B0-24-00-00-00", + "model": "L920", + "music_rhythm_enable": false, + "music_rhythm_mode": "single_lamp", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Europe/Bucharest", + "rssi": -57, + "saturation": 0, + "segment_effect": { + "brightness": 97, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "Warm Aurora" + }, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 120, + "type": "SMART.TAPOBULB" + }, + "get_device_segment": { + "segment": 50 + }, + "get_device_time": { + "region": "Europe/Bucharest", + "time_diff": 120, + "timestamp": 1720089009 + }, + "get_device_usage": { + "power_usage": { + "past30": 1211, + "past7": 183, + "today": 7 + }, + "saved_power": { + "past30": 6124, + "past7": 1204, + "today": 30 + }, + "time_usage": { + "past30": 7335, + "past7": 1387, + "today": 37 + } + }, + "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.3 Build 231229 Rel.164316", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_lighting_effect": { + "brightness": 100, + "custom": 0, + "direction": 1, + "display_colors": [ + [ + 30, + 0, + 100 + ], + [ + 30, + 95, + 100 + ], + [ + 0, + 100, + 100 + ] + ], + "duration": 600, + "enable": 0, + "expansion_strategy": 2, + "id": "TapoStrip_1OVSyXIsDxrt4j7OxyRvqi", + "name": "Sunrise", + "repeat_times": 1, + "run_time": 0, + "segments": [ + 0 + ], + "sequence": [ + [ + 0, + 100, + 5 + ], + [ + 0, + 100, + 5 + ], + [ + 10, + 100, + 6 + ], + [ + 15, + 100, + 7 + ], + [ + 20, + 100, + 8 + ], + [ + 20, + 100, + 10 + ], + [ + 30, + 100, + 12 + ], + [ + 30, + 95, + 15 + ], + [ + 30, + 90, + 20 + ], + [ + 30, + 80, + 25 + ], + [ + 30, + 75, + 30 + ], + [ + 30, + 70, + 40 + ], + [ + 30, + 60, + 50 + ], + [ + 30, + 50, + 60 + ], + [ + 30, + 20, + 70 + ], + [ + 30, + 0, + 100 + ] + ], + "spread": 1, + "trans_sequence": [], + "transition": 60000, + "type": "pulse" + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "enable": true + }, + "get_preset_rules": { + "start_index": 0, + "states": [ + { + "brightness": 50, + "color_temp": 9000, + "hue": 0, + "saturation": 100 + }, + { + "lighting_effect": { + "brightness": 100, + "custom": 0, + "direction": 1, + "display_colors": [ + [ + 30, + 0, + 100 + ], + [ + 30, + 95, + 100 + ], + [ + 0, + 100, + 100 + ] + ], + "duration": 600, + "enable": 1, + "expansion_strategy": 2, + "id": "TapoStrip_1OVSyXIsDxrt4j7OxyRvqi", + "name": "Sunrise", + "repeat_times": 1, + "run_time": 0, + "segments": [ + 0 + ], + "sequence": [ + [ + 0, + 100, + 5 + ], + [ + 0, + 100, + 5 + ], + [ + 10, + 100, + 6 + ], + [ + 15, + 100, + 7 + ], + [ + 20, + 100, + 8 + ], + [ + 20, + 100, + 10 + ], + [ + 30, + 100, + 12 + ], + [ + 30, + 95, + 15 + ], + [ + 30, + 90, + 20 + ], + [ + 30, + 80, + 25 + ], + [ + 30, + 75, + 30 + ], + [ + 30, + 70, + 40 + ], + [ + 30, + 60, + 50 + ], + [ + 30, + 50, + 60 + ], + [ + 30, + 20, + 70 + ], + [ + 30, + 0, + 100 + ] + ], + "spread": 1, + "trans_sequence": [], + "transition": 60000, + "type": "pulse" + } + }, + { + "lighting_effect": { + "brightness": 100, + "custom": 0, + "direction": 1, + "display_colors": [ + [ + 0, + 100, + 100 + ], + [ + 30, + 95, + 100 + ], + [ + 30, + 0, + 100 + ] + ], + "duration": 600, + "enable": 1, + "expansion_strategy": 2, + "id": "TapoStrip_5NiN0Y8GAUD78p4neKk9EL", + "name": "Sunset", + "repeat_times": 1, + "run_time": 0, + "segments": [ + 0 + ], + "sequence": [ + [ + 30, + 0, + 100 + ], + [ + 30, + 20, + 100 + ], + [ + 30, + 50, + 99 + ], + [ + 30, + 60, + 98 + ], + [ + 30, + 70, + 97 + ], + [ + 30, + 75, + 95 + ], + [ + 30, + 80, + 93 + ], + [ + 30, + 90, + 90 + ], + [ + 30, + 95, + 85 + ], + [ + 30, + 100, + 80 + ], + [ + 20, + 100, + 70 + ], + [ + 20, + 100, + 60 + ], + [ + 15, + 100, + 50 + ], + [ + 10, + 100, + 40 + ], + [ + 0, + 100, + 30 + ], + [ + 0, + 100, + 0 + ] + ], + "spread": 1, + "trans_sequence": [], + "transition": 60000, + "type": "pulse" + } + }, + { + "lighting_effect": { + "brightness": 100, + "custom": 0, + "direction": 1, + "display_colors": [ + [ + 0, + 100, + 100 + ], + [ + 100, + 100, + 100 + ], + [ + 200, + 100, + 100 + ], + [ + 300, + 100, + 100 + ] + ], + "duration": 0, + "enable": 1, + "expansion_strategy": 1, + "id": "TapoStrip_7CC5y4lsL8pETYvmz7UOpQ", + "name": "Rainbow", + "repeat_times": 0, + "segments": [ + 0 + ], + "sequence": [ + [ + 0, + 100, + 100 + ], + [ + 100, + 100, + 100 + ], + [ + 200, + 100, + 100 + ], + [ + 300, + 100, + 100 + ] + ], + "spread": 12, + "transition": 1500, + "type": "sequence" + } + }, + { + "lighting_effect": { + "brightness": 100, + "custom": 0, + "direction": 4, + "display_colors": [ + [ + 120, + 100, + 100 + ], + [ + 240, + 100, + 100 + ], + [ + 260, + 100, + 100 + ], + [ + 280, + 100, + 100 + ] + ], + "duration": 0, + "enable": 1, + "expansion_strategy": 1, + "id": "TapoStrip_1MClvV18i15Jq3bvJVf0eP", + "name": "Aurora", + "repeat_times": 0, + "segments": [ + 0 + ], + "sequence": [ + [ + 120, + 100, + 100 + ], + [ + 240, + 100, + 100 + ], + [ + 260, + 100, + 100 + ], + [ + 280, + 100, + 100 + ] + ], + "spread": 7, + "transition": 1500, + "type": "sequence" + } + }, + { + "lighting_effect": { + "brightness": 100, + "custom": 1, + "direction": 4, + "display_colors": [ + [ + 103, + 100, + 100 + ], + [ + 73, + 100, + 100 + ], + [ + 16, + 100, + 100 + ], + [ + 44, + 100, + 100 + ] + ], + "duration": 0, + "enable": 1, + "expansion_strategy": 1, + "id": "TapoStrip_639hjRuGECd1gsSbFAINNn", + "name": "Warm Aurora", + "repeat_times": 0, + "segments": [ + 0 + ], + "sequence": [ + [ + 103, + 100, + 100 + ], + [ + 73, + 100, + 100 + ], + [ + 16, + 100, + 100 + ], + [ + 44, + 100, + 100 + ] + ], + "spread": 7, + "transition": 5000, + "type": "sequence" + } + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 0, + "saturation": 100 + } + ], + "sum": 7 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 24, + "start_index": 0, + "sum": 0 + }, + "get_segment_effect_rule": { + "brightness": 97, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "Warm Aurora" + }, + "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": 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": 24, + "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": "L920", + "device_type": "SMART.TAPOBULB", + "is_klap": true + } + } +} 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 new file mode 100644 index 000000000..95e8f969e --- /dev/null +++ b/tests/fixtures/smart/L920-5(US)_1.0_1.1.0.json @@ -0,0 +1,400 @@ +{ + "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": "bulb_quick_control", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "segment", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "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, + "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": "light_strip", + "brightness": 100, + "color_temp": 9000, + "color_temp_range": [ + 9000, + 9000 + ], + "default_states": { + "state": { + "brightness": 100, + "color_temp": 9000, + "hue": 0, + "saturation": 100 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.0 Build 230905 Rel.190143", + "has_set_location_info": true, + "hue": 0, + "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": "00-00-00-00-00-00", + "model": "L920", + "music_rhythm_enable": false, + "music_rhythm_mode": "single_lamp", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Pacific/Honolulu", + "rssi": -34, + "saturation": 100, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -600, + "type": "SMART.TAPOBULB" + }, + "get_device_segment": { + "segment": 50 + }, + "get_device_time": { + "region": "Pacific/Honolulu", + "time_diff": -600, + "timestamp": 1705270860 + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 4, + "past7": 4, + "today": 4 + }, + "time_usage": { + "past30": 4, + "past7": 4, + "today": 4 + } + }, + "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.0 Build 230905 Rel.190143", + "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": 9000, + "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 + } + ], + "sum": 7 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 24, + "start_index": 0, + "sum": 0 + }, + "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 + } + ], + "extra_info": { + "device_model": "L920", + "device_type": "SMART.TAPOBULB", + "is_klap": true + } + } +} 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 new file mode 100644 index 000000000..992f63999 --- /dev/null +++ b/tests/fixtures/smart/L920-5(US)_1.0_1.1.3.json @@ -0,0 +1,418 @@ +{ + "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": "bulb_quick_control", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "segment", + "ver_code": 1 + }, + { + "id": "segment_effect", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "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, + "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": "light_strip", + "brightness": 100, + "color_temp": 0, + "color_temp_range": [ + 9000, + 9000 + ], + "default_states": { + "state": { + "brightness": 100, + "color_temp": 0, + "hue": 250, + "saturation": 85 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.3 Build 231229 Rel.164316", + "has_set_location_info": false, + "hue": 250, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "lighting_effect": { + "brightness": 50, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "station" + }, + "mac": "34-60-F9-00-00-00", + "model": "L920", + "music_rhythm_enable": false, + "music_rhythm_mode": "single_lamp", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Pacific/Honolulu", + "rssi": -32, + "saturation": 85, + "segment_effect": { + "brightness": 0, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "station" + }, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -600, + "type": "SMART.TAPOBULB" + }, + "get_device_segment": { + "segment": 50 + }, + "get_device_time": { + "region": "Pacific/Honolulu", + "time_diff": -600, + "timestamp": 1705991901 + }, + "get_device_usage": { + "power_usage": { + "past30": 8, + "past7": 7, + "today": 0 + }, + "saved_power": { + "past30": 110, + "past7": 101, + "today": 14 + }, + "time_usage": { + "past30": 118, + "past7": 108, + "today": 14 + } + }, + "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.3 Build 231229 Rel.164316", + "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": 9000, + "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 + } + ], + "sum": 7 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 24, + "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": [], + "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": "L920", + "device_type": "SMART.TAPOBULB", + "is_klap": true + } + } +} 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 new file mode 100644 index 000000000..c374ebc5c --- /dev/null +++ b/tests/fixtures/smart/L930-5(US)_1.0_1.1.2.json @@ -0,0 +1,432 @@ +{ + "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": "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(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, + "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": 4500, + "color_temp_range": [ + 2500, + 6500 + ], + "default_states": { + "state": { + "brightness": 100, + "color_temp": 4500, + "hue": 0, + "saturation": 100 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.2 Build 231212 Rel.210005", + "has_set_location_info": true, + "hue": 0, + "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": "3C-52-A1-00-00-00", + "model": "L930", + "music_rhythm_enable": false, + "music_rhythm_mode": "single_lamp", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Pacific/Honolulu", + "rssi": -37, + "saturation": 100, + "segment_effect": { + "brightness": 0, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "station" + }, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -600, + "type": "SMART.TAPOBULB" + }, + "get_device_segment": { + "segment": 50 + }, + "get_device_time": { + "region": "Pacific/Honolulu", + "time_diff": -600, + "timestamp": 1706061664 + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 6, + "past7": 6, + "today": 6 + }, + "time_usage": { + "past30": 6, + "past7": 6, + "today": 6 + } + }, + "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.2 Build 231212 Rel.210005", + "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 + } + ], + "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": [], + "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/P100(US)_1.0.0_1.1.3.json b/tests/fixtures/smart/P100(US)_1.0.0_1.1.3.json new file mode 100644 index 000000000..2ae738cdc --- /dev/null +++ b/tests/fixtures/smart/P100(US)_1.0.0_1.1.3.json @@ -0,0 +1,176 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "quick_setup", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 1 + }, + { + "id": "countdown", + "ver_code": 1 + }, + { + "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 + } + ] + }, + "discovery_result": { + "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, + "enable": false, + "rule_list": [] + }, + "get_connect_cloud_state": { + "status": -1001 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "plug", + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.3 Build 20191017 Rel. 57937", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0.0", + "ip": "127.0.0.123", + "latitude": 0, + "location": "#MASKED_NAME#", + "longitude": 0, + "mac": "1C-3B-F3-00-00-00", + "model": "P100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 6868, + "overheated": false, + "signal_level": 2, + "specs": "US", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_usage_past30": 114, + "time_usage_past7": 114, + "time_usage_today": 114, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "Europe/London", + "time_diff": 0, + "timestamp": 1707905077 + }, + "get_fw_download_state": { + "download_progress": 0, + "reboot_time": 10, + "status": 0, + "upgrade_time": 0 + }, + "get_latest_fw": { + "fw_size": 786432, + "fw_ver": "1.3.7 Build 20230711 Rel.61904", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2023-07-26", + "release_note": "Modifications and Bug fixes:\nEnhanced device security.", + "type": 3 + }, + "get_led_info": { + "led_rule": "always", + "led_status": true + }, + "get_next_event": { + "action": -1, + "e_time": 0, + "id": "0", + "s_time": 0, + "type": 0 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 20, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [], + "start_index": 0, + "sum": 0, + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + } + ], + "extra_info": { + "device_model": "P100", + "device_type": "SMART.TAPOPLUG" + } + } +} 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 new file mode 100644 index 000000000..5347d070b --- /dev/null +++ b/tests/fixtures/smart/P100(US)_1.0.0_1.3.7.json @@ -0,0 +1,200 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "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": "default_states", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "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, + "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": "plug", + "default_states": { + "state": {}, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.3.7 Build 20230711 Rel. 61904", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "location": "#MASKED_NAME#", + "longitude": 0, + "mac": "CC-32-E5-00-00-00", + "model": "P100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheated": false, + "region": "Pacific/Honolulu", + "rssi": -48, + "signal_level": 3, + "specs": "US", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -600, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "Pacific/Honolulu", + "time_diff": -600, + "timestamp": 1705995478 + }, + "get_device_usage": { + "time_usage": { + "past30": 0, + "past7": 0, + "today": 0 + } + }, + "get_fw_download_state": { + "download_progress": 0, + "reboot_time": 10, + "status": 0, + "upgrade_time": 0 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.3.7 Build 20230711 Rel. 61904", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "led_rule": "always", + "led_status": false + }, + "get_next_event": { + "action": -1, + "e_time": 0, + "id": "0", + "s_time": 0, + "type": 0 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 20, + "start_index": 0, + "sum": 0 + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + } + ], + "extra_info": { + "device_model": "P100", + "device_type": "SMART.TAPOPLUG" + } + } +} 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 new file mode 100644 index 000000000..ab75faf5d --- /dev/null +++ b/tests/fixtures/smart/P100(US)_1.0.0_1.4.0.json @@ -0,0 +1,207 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "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": "default_states", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "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, + "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": "", + "default_states": { + "state": {}, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.4.0 Build 20231017 Rel. 33876", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "location": "", + "longitude": 0, + "mac": "74-DA-88-00-00-00", + "model": "P100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheated": false, + "region": "Europe/London", + "rssi": -57, + "signal_level": 2, + "specs": "US", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 0, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "Europe/London", + "time_diff": 0, + "timestamp": 1710256253 + }, + "get_device_usage": { + "time_usage": { + "past30": 0, + "past7": 0, + "today": 0 + } + }, + "get_fw_download_state": { + "download_progress": 0, + "reboot_time": 10, + "status": 0, + "upgrade_time": 0 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.4.0 Build 20231017 Rel. 33876", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "led_rule": "always", + "led_status": false + }, + "get_next_event": { + "action": -1, + "e_time": 0, + "id": "0", + "s_time": 0, + "type": 0 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 20, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [], + "start_index": 0, + "sum": 0, + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + } + ], + "extra_info": { + "device_model": "P100", + "device_type": "SMART.TAPOPLUG", + "is_klap": true + } + } +} 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 new file mode 100644 index 000000000..dd7a0360d --- /dev/null +++ b/tests/fixtures/smart/P110(EU)_1.0_1.0.7.json @@ -0,0 +1,569 @@ +{ + "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": "energy_monitoring", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "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, + "enable": false, + "rule_list": [] + }, + "get_auto_update_info": { + "enable": false, + "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": "plug", + "default_states": { + "state": {}, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.7 Build 210629 Rel.174901", + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "34-60-F9-00-00-00", + "model": "P110", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheated": false, + "region": "Europe/Lisbon", + "rssi": -55, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 0, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "Europe/Lisbon", + "time_diff": 0, + "timestamp": 1708990159 + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "time_usage": { + "past30": 0, + "past7": 0, + "today": 0 + } + }, + "get_energy_usage": { + "current_power": 0, + "local_time": "2024-02-26 23:29:21", + "month_energy": 0, + "month_runtime": 0, + "past1y": [ + 0, + 55, + 416, + 440, + 146, + 204, + 95, + 101, + 0, + 0, + 0, + 0 + ], + "past24h": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "past30d": [ + 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 + ], + "past7d": [ + [ + 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, + 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, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + ], + "today_energy": 0, + "today_runtime": 0 + }, + "get_fw_download_state": { + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 786432, + "fw_ver": "1.3.0 Build 230905 Rel.152200", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2023-10-31", + "release_note": "Modifications and Bug Fixes:\n1. Improved stability and performance.\n2. Enhanced local communication security.", + "type": 2 + }, + "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": 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": 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": "none", + "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": 0, + "key_type": "none", + "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": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "P110", + "device_type": "SMART.TAPOPLUG" + } + } +} 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 new file mode 100644 index 000000000..62e580fcd --- /dev/null +++ b/tests/fixtures/smart/P110(EU)_1.0_1.2.3.json @@ -0,0 +1,402 @@ +{ + "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": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "power_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "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, + "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": 1 + }, + "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", + "default_states": { + "state": {}, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.2.3 Build 230425 Rel.142542", + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "48-22-54-00-00-00", + "model": "P110", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheated": false, + "power_protection_status": "normal", + "region": "Europe/Berlin", + "rssi": -42, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 60, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "Europe/Berlin", + "time_diff": 60, + "timestamp": 1705699998 + }, + "get_device_usage": { + "power_usage": { + "past30": 1010, + "past7": 368, + "today": 46 + }, + "saved_power": { + "past30": 26144, + "past7": 9536, + "today": 1218 + }, + "time_usage": { + "past30": 27154, + "past7": 9904, + "today": 1264 + } + }, + "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_energy_usage": { + "current_power": 0, + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2024-01-19 22:33:18", + "month_energy": 430, + "month_runtime": 11571, + "today_energy": 46, + "today_runtime": 1264 + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "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_max_power": { + "max_power": 3904 + }, + "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": "wpa_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "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" + } + } +} 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 new file mode 100644 index 000000000..0c7f6e83a --- /dev/null +++ b/tests/fixtures/smart/P110(UK)_1.0_1.3.0.json @@ -0,0 +1,450 @@ +{ + "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": "current_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "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, + "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": [ + { + "delay": 300, + "desired_states": { + "on": true + }, + "enable": false, + "id": "C1", + "remain": 0 + } + ] + }, + "get_current_power": { + "current_power": 0 + }, + "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.3.0 Build 230905 Rel.152200", + "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": "48-22-54-00-00-00", + "model": "P110", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overcurrent_status": "normal", + "overheated": false, + "power_protection_status": "normal", + "region": "Europe/London", + "rssi": -45, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 0, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "Europe/London", + "time_diff": 0, + "timestamp": 1705742730 + }, + "get_device_usage": { + "power_usage": { + "past30": 3205, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 26000, + "past7": 7265, + "today": 3 + }, + "time_usage": { + "past30": 29205, + "past7": 7265, + "today": 3 + } + }, + "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_energy_usage": { + "current_power": 0, + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2024-01-20 09:25:31", + "month_energy": 1421, + "month_runtime": 19742, + "today_energy": 0, + "today_runtime": 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.3.0 Build 230905 Rel.152200", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "led_rule": "night_mode", + "led_status": false, + "night_mode": { + "end_time": 538, + "night_mode_type": "sunrise_sunset", + "start_time": 989, + "sunrise_offset": 62, + "sunset_offset": 0 + } + }, + "get_max_power": { + "max_power": 3379 + }, + "get_next_event": { + "desired_states": { + "on": true + }, + "e_time": 0, + "id": "S1", + "s_time": 1705751400, + "type": 1 + }, + "get_protection_power": { + "enabled": true, + "protection_power": 2960 + }, + "get_schedule_rules": { + "enable": true, + "rule_list": [ + { + "day": 20, + "desired_states": { + "on": true + }, + "e_action": "none", + "e_min": 0, + "e_type": "normal", + "enable": true, + "id": "S1", + "mode": "repeat", + "month": 1, + "s_min": 710, + "s_type": "normal", + "time_offset": 0, + "week_day": 127, + "year": 2024 + } + ], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 1 + }, + "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": "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/P110M(AU)_1.0_1.2.3.json b/tests/fixtures/smart/P110M(AU)_1.0_1.2.3.json new file mode 100644 index 000000000..2fea43797 --- /dev/null +++ b/tests/fixtures/smart/P110M(AU)_1.0_1.2.3.json @@ -0,0 +1,421 @@ +{ + "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": "matter", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "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, + "enable": false + }, + "get_auto_update_info": { + "enable": false, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 1 + }, + "get_current_power": { + "current_power": 74 + }, + "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": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.2.3 Build 240617 Rel.153525", + "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": "P110M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 186533, + "overcurrent_status": "normal", + "overheat_status": "normal", + "power_protection_status": "normal", + "region": "Australia/Sydney", + "rssi": -53, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 600, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "Australia/Sydney", + "time_diff": 600, + "timestamp": 946958455 + }, + "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_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, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "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": "sunrise_sunset", + "start_time": 1140, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:0000000000000000000" + }, + "get_max_power": { + "max_power": 2465 + }, + "get_next_event": {}, + "get_protection_power": { + "enabled": true, + "protection_power": 1120 + }, + "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": 1, + "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": "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": "P110M", + "device_type": "SMART.TAPOPLUG", + "is_klap": true + } + } +} 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 new file mode 100644 index 000000000..81174d7b7 --- /dev/null +++ b/tests/fixtures/smart/P110M(EU)_1.0_1.2.3.json @@ -0,0 +1,617 @@ +{ + "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": "matter", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "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, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "get_auto_update_info": { + "enable": false, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 1 + }, + "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": "", + "charging_status": "normal", + "default_states": { + "state": {}, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.2.3 Build 240617 Rel.153525", + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "longitude": 0, + "mac": "F0-A7-31-00-00-00", + "model": "P110M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overcurrent_status": "normal", + "overheat_status": "normal", + "power_protection_status": "normal", + "region": "CET", + "rssi": -33, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 60, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "CET", + "time_diff": 60, + "timestamp": 1732361090 + }, + "get_device_usage": { + "power_usage": { + "past30": 7892, + "past7": 1549, + "today": 0 + }, + "saved_power": { + "past30": 9381, + "past7": 1362, + "today": 0 + }, + "time_usage": { + "past30": 17273, + "past7": 2911, + "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": 1469, + "power_mw": 0, + "voltage_mv": 233509 + }, + "get_emeter_vgain_igain": { + "igain": 11299, + "vgain": 124300 + }, + "get_energy_usage": { + "current_power": 0, + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2024-11-23 12:24:51", + "month_energy": 6266, + "month_runtime": 12705, + "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_led_info": { + "bri_config": { + "bri_type": "overall", + "overall_bri": 50 + }, + "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_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:000000-000000000000" + }, + "get_max_power": { + "max_power": 3896 + }, + "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": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 0, + "key_type": "none", + "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": 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": 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": 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==" + } + ], + "start_index": 0, + "sum": 22, + "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": "P110M", + "device_type": "SMART.TAPOPLUG", + "is_klap": true + } + } +} 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 new file mode 100644 index 000000000..33d7465cc --- /dev/null +++ b/tests/fixtures/smart/P115(EU)_1.0_1.2.3.json @@ -0,0 +1,389 @@ +{ + "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": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "power_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "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, + "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": 1 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_current_power": { + "current_power": 9 + }, + "get_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "", + "default_states": { + "state": {}, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.2.3 Build 230425 Rel.142542", + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "longitude": 0, + "mac": "A8-42-A1-00-00-00", + "model": "P115", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 1621, + "overheated": false, + "power_protection_status": "normal", + "region": "UTC", + "rssi": -45, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 0, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "UTC", + "time_diff": 0, + "timestamp": 1717512486 + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 6, + "past7": 6, + "today": 6 + }, + "time_usage": { + "past30": 6, + "past7": 6, + "today": 6 + } + }, + "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_energy_usage": { + "current_power": 8962, + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2024-06-04 14:48:06", + "month_energy": 0, + "month_runtime": 6, + "today_energy": 0, + "today_runtime": 6 + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_led_info": { + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 420, + "night_mode_type": "sunrise_sunset", + "start_time": 1140, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_max_power": { + "max_power": 3895 + }, + "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 + }, + "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": "P115", + "device_type": "SMART.TAPOPLUG" + } + } +} 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 new file mode 100644 index 000000000..1e0cf7e2b --- /dev/null +++ b/tests/fixtures/smart/P125M(US)_1.0_1.1.0.json @@ -0,0 +1,246 @@ +{ + "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 + } + ] + }, + "discovery_result": { + "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, + "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": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.0 Build 231009 Rel.155831", + "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": "48-22-54-00-00-00", + "model": "P125M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 189479, + "overheated": false, + "region": "Pacific/Honolulu", + "rssi": -43, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -600, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "Pacific/Honolulu", + "time_diff": -600, + "timestamp": 1705991899 + }, + "get_device_usage": { + "time_usage": { + "past30": 3163, + "past7": 3163, + "today": 1238 + } + }, + "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.0 Build 231009 Rel.155831", + "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": 427, + "night_mode_type": "sunrise_sunset", + "start_time": 1092, + "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": [], + "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": "P125M", + "device_type": "SMART.TAPOPLUG", + "is_klap": true + } + } +} 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 new file mode 100644 index 000000000..f1099cc77 --- /dev/null +++ b/tests/fixtures/smart/P135(US)_1.0_1.0.5.json @@ -0,0 +1,320 @@ +{ + "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": "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, + "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": "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": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 230801 Rel.095702", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "longitude": 0, + "mac": "F0-A7-31-00-00-00", + "model": "P135", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheat_status": "normal", + "region": "Pacific/Honolulu", + "rssi": -43, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -600, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "Pacific/Honolulu", + "time_diff": -600, + "timestamp": 1705975451 + }, + "get_device_usage": { + "time_usage": { + "past30": 0, + "past7": 0, + "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.0.5 Build 230801 Rel.095702", + "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": 427, + "night_mode_type": "sunrise_sunset", + "start_time": 1092, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_next_event": {}, + "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": "wpa_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "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" + } + } +} 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 new file mode 100644 index 000000000..73f76e83c --- /dev/null +++ b/tests/fixtures/smart/P300(EU)_1.0_1.0.13.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": "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": "homekit", + "ver_code": 2 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "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, + "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": "overheat_protection", + "ver_code": 1 + } + ], + "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": "overheat_protection", + "ver_code": 1 + } + ], + "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": "overheat_protection", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3" + } + ], + "start_index": 0, + "sum": 3 + }, + "get_child_device_list": { + "child_device_list": [ + { + "avatar": "", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "state": { + "on": false + }, + "type": "custom" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.13 Build 230925 Rel.150200", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude": 0, + "longitude": 0, + "mac": "788CB5000000", + "model": "P300", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 3, + "region": "Europe/Berlin", + "slot_number": 3, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "avatar": "", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.13 Build 230925 Rel.150200", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude": 0, + "longitude": 0, + "mac": "788CB5000000", + "model": "P300", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 2, + "region": "Europe/Berlin", + "slot_number": 3, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "avatar": "", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "state": { + "on": true + }, + "type": "custom" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.13 Build 230925 Rel.150200", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude": 0, + "longitude": 0, + "mac": "788CB5000000", + "model": "P300", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 1, + "region": "Europe/Berlin", + "slot_number": 3, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + } + ], + "start_index": 0, + "sum": 3 + }, + "get_connect_cloud_state": { + "status": 2 + }, + "get_device_info": { + "avatar": "", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.13 Build 230925 Rel.150200", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "de_DE", + "latitude": 0, + "longitude": 0, + "mac": "78-8C-B5-00-00-00", + "model": "P300", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "region": "Europe/Berlin", + "rssi": -62, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 60, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "Europe/Berlin", + "time_diff": 60, + "timestamp": 1706810351 + }, + "get_device_usage": { + "time_usage": { + "past30": 134, + "past7": 134, + "today": 0 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_led_info": { + "led_rule": "night_mode", + "led_status": false, + "night_mode": { + "end_time": 489, + "night_mode_type": "sunrise_sunset", + "start_time": 1043, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "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": "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": "P300", + "device_type": "SMART.TAPOPLUG", + "is_klap": true + } + } +} 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 new file mode 100644 index 000000000..e9d4b54ff --- /dev/null +++ b/tests/fixtures/smart/P300(EU)_1.0_1.0.15.json @@ -0,0 +1,969 @@ +{ + "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": "overheat_protection", + "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": "", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "state": { + "on": false + }, + "type": "custom" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.15 Build 231130 Rel.122554", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude": 0, + "longitude": 0, + "mac": "788CB5000000", + "model": "P300", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 441974, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 3, + "region": "Europe/Berlin", + "slot_number": 3, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "time_usage": { + "past30": 30367, + "past7": 4909, + "today": 756 + } + }, + "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": "overheat_protection", + "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": "", + "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.0.15 Build 231130 Rel.122554", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude": 0, + "longitude": 0, + "mac": "788CB5000000", + "model": "P300", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 441975, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 2, + "region": "Europe/Berlin", + "slot_number": 3, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "time_usage": { + "past30": 18287, + "past7": 4909, + "today": 756 + } + }, + "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": "overheat_protection", + "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": "", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "state": { + "on": true + }, + "type": "custom" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.15 Build 231130 Rel.122554", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude": 0, + "longitude": 0, + "mac": "788CB5000000", + "model": "P300", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 441975, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 1, + "region": "Europe/Berlin", + "slot_number": 3, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "time_usage": { + "past30": 30383, + "past7": 4909, + "today": 756 + } + }, + "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": "auto_off", + "ver_code": 2 + }, + { + "id": "homekit", + "ver_code": 2 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "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, + "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": "overheat_protection", + "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": "overheat_protection", + "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": "overheat_protection", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3" + } + ], + "start_index": 0, + "sum": 3 + }, + "get_child_device_list": { + "child_device_list": [ + { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "state": { + "on": false + }, + "type": "custom" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.15 Build 231130 Rel.122554", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude": 0, + "longitude": 0, + "mac": "788CB5000000", + "model": "P300", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 441972, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 3, + "region": "Europe/Berlin", + "slot_number": 3, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "", + "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.0.15 Build 231130 Rel.122554", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude": 0, + "longitude": 0, + "mac": "788CB5000000", + "model": "P300", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 441972, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 2, + "region": "Europe/Berlin", + "slot_number": 3, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "state": { + "on": true + }, + "type": "custom" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.15 Build 231130 Rel.122554", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude": 0, + "longitude": 0, + "mac": "788CB5000000", + "model": "P300", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 441972, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 1, + "region": "Europe/Berlin", + "slot_number": 3, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + } + ], + "start_index": 0, + "sum": 3 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "avatar": "", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.15 Build 231130 Rel.122554", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "de_DE", + "latitude": 0, + "longitude": 0, + "mac": "78-8C-B5-00-00-00", + "model": "P300", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "region": "Europe/Berlin", + "rssi": -61, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 60, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "Europe/Berlin", + "time_diff": 60, + "timestamp": 1715622973 + }, + "get_device_usage": { + "time_usage": { + "past30": 30383, + "past7": 4909, + "today": 756 + } + }, + "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": "00000000000000000000000000000000000000000000000000000000000000000000000000000/00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000/000000000000000000==", + "mfi_token_uuid": "00000000-0000-0000-0000-000000000000" + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.0.15 Build 231130 Rel.122554", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "led_rule": "never", + "led_status": false, + "night_mode": { + "end_time": 340, + "night_mode_type": "sunrise_sunset", + "start_time": 1277, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "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": "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": "P300", + "device_type": "SMART.TAPOPLUG", + "is_klap": true + } + } +} 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 new file mode 100644 index 000000000..eaa03a35e --- /dev/null +++ b/tests/fixtures/smart/P300(EU)_1.0_1.0.7.json @@ -0,0 +1,515 @@ +{ + "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": "homekit", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "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, + "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": "overheat_protection", + "ver_code": 1 + } + ], + "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": "overheat_protection", + "ver_code": 1 + } + ], + "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": "overheat_protection", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3" + } + ], + "start_index": 0, + "sum": 3 + }, + "get_child_device_list": { + "child_device_list": [ + { + "avatar": "", + "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.0.7 Build 220715 Rel.200458", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude": 0, + "longitude": 0, + "mac": "788CB5000000", + "model": "P300", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 366, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 1, + "region": "Europe/Berlin", + "slot_number": 3, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "avatar": "", + "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.0.7 Build 220715 Rel.200458", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude": 0, + "longitude": 0, + "mac": "788CB5000000", + "model": "P300", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 366, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 2, + "region": "Europe/Berlin", + "slot_number": 3, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "avatar": "", + "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.0.7 Build 220715 Rel.200458", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude": 0, + "longitude": 0, + "mac": "788CB5000000", + "model": "P300", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 366, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 3, + "region": "Europe/Berlin", + "slot_number": 3, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + } + ], + "start_index": 0, + "sum": 3 + }, + "get_connect_cloud_state": { + "status": 1 + }, + "get_device_info": { + "avatar": "", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.7 Build 220715 Rel.200458", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "longitude": 0, + "mac": "78-8C-B5-00-00-00", + "model": "P300", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "region": "Europe/Berlin", + "rssi": -58, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 60, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "Europe/Berlin", + "time_diff": 60, + "timestamp": 1706297406 + }, + "get_device_usage": { + "time_usage": { + "past30": 5, + "past7": 5, + "today": 5 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_led_info": { + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 497, + "night_mode_type": "sunrise_sunset", + "start_time": 1032, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "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": "P300", + "device_type": "SMART.TAPOPLUG" + } + } +} 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 new file mode 100644 index 000000000..398977ada --- /dev/null +++ b/tests/fixtures/smart/P304M(UK)_1.0_1.0.3.json @@ -0,0 +1,2265 @@ +{ + "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_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": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.3 Build 240605 Rel.091502", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P304M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "overheat_status": "normal", + "position": 1, + "power_protection_status": "normal", + "protection_enabled": false, + "protection_power": 0, + "region": "Europe/London", + "slot_number": 4, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "power_usage": { + "past30": 14, + "past7": 14, + "today": 0 + }, + "saved_power": { + "past30": 206, + "past7": 206, + "today": 0 + }, + "time_usage": { + "past30": 220, + "past7": 220, + "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_energy_usage": { + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2024-10-22 13:30:15", + "month_energy": 14, + "month_runtime": 220, + "today_energy": 0, + "today_runtime": 0 + }, + "get_max_power": { + "max_power": 3252 + }, + "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": "overheat_protection", + "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": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.3 Build 240605 Rel.091502", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P304M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "overheat_status": "normal", + "position": 2, + "power_protection_status": "normal", + "protection_enabled": false, + "protection_power": 0, + "region": "Europe/London", + "slot_number": 4, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 20, + "past7": 20, + "today": 0 + }, + "time_usage": { + "past30": 20, + "past7": 20, + "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_energy_usage": { + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2024-10-22 13:30:15", + "month_energy": 0, + "month_runtime": 20, + "today_energy": 0, + "today_runtime": 0 + }, + "get_max_power": { + "max_power": 3244 + }, + "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_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_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_3", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.3 Build 240605 Rel.091502", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P304M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "overheat_status": "normal", + "position": 3, + "power_protection_status": "normal", + "protection_enabled": false, + "protection_power": 0, + "region": "Europe/London", + "slot_number": 4, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 18, + "past7": 18, + "today": 0 + }, + "time_usage": { + "past30": 18, + "past7": 18, + "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_energy_usage": { + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2024-10-22 13:30:15", + "month_energy": 0, + "month_runtime": 18, + "today_energy": 0, + "today_runtime": 0 + }, + "get_max_power": { + "max_power": 3262 + }, + "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_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_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_4", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.3 Build 240605 Rel.091502", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P304M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "overheat_status": "normal", + "position": 4, + "power_protection_status": "normal", + "protection_enabled": false, + "protection_power": 0, + "region": "Europe/London", + "slot_number": 4, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + "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_energy_usage": { + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2024-10-22 13:30:15", + "month_energy": 24, + "month_runtime": 432, + "today_energy": 0, + "today_runtime": 0 + }, + "get_max_power": { + "max_power": 3262 + }, + "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": "overheat_protection", + "ver_code": 1 + }, + { + "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": "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, + "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_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": "overheat_protection", + "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": "overheat_protection", + "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": "overheat_protection", + "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" + } + ], + "start_index": 0, + "sum": 4 + }, + "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": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.3 Build 240605 Rel.091502", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P304M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "overheat_status": "normal", + "position": 1, + "power_protection_status": "normal", + "protection_enabled": false, + "protection_power": 0, + "region": "Europe/London", + "slot_number": 4, + "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": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.3 Build 240605 Rel.091502", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P304M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "overheat_status": "normal", + "position": 2, + "power_protection_status": "normal", + "protection_enabled": false, + "protection_power": 0, + "region": "Europe/London", + "slot_number": 4, + "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": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.3 Build 240605 Rel.091502", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P304M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "overheat_status": "normal", + "position": 3, + "power_protection_status": "normal", + "protection_enabled": false, + "protection_power": 0, + "region": "Europe/London", + "slot_number": 4, + "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": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.3 Build 240605 Rel.091502", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "is_usb": false, + "latitude": 0, + "longitude": 0, + "mac": "A86E84000000", + "model": "P304M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overcurrent_status": "normal", + "overheat_status": "normal", + "position": 4, + "power_protection_status": "normal", + "protection_enabled": false, + "protection_power": 0, + "region": "Europe/London", + "slot_number": 4, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + } + ], + "start_index": 0, + "sum": 4 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "avatar": "", + "device_id": "0000000000000000000000000000000000000000", + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.3 Build 240605 Rel.091502", + "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": "P304M", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "region": "Europe/London", + "rssi": -44, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": 0, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "Europe/London", + "time_diff": 0, + "timestamp": 1729600212 + }, + "get_device_usage": { + "power_usage": { + "past30": 14, + "past7": 14, + "today": 0 + }, + "saved_power": { + "past30": 206, + "past7": 206, + "today": 0 + }, + "time_usage": { + "past30": 220, + "past7": 220, + "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_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 240605 Rel.091502", + "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": "night_mode", + "led_status": true, + "night_mode": { + "end_time": 461, + "night_mode_type": "sunrise_sunset", + "start_time": 1077, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:0-00000000000000000" + }, + "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": 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": 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==" + } + ], + "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": "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": "P304M", + "device_type": "SMART.TAPOPLUG", + "is_klap": 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 new file mode 100644 index 000000000..3e6ec48df --- /dev/null +++ b/tests/fixtures/smart/S500D(US)_1.0_1.0.5.json @@ -0,0 +1,320 @@ +{ + "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 + } + ] + }, + "discovery_result": { + "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, + "enable": false, + "rule_list": [] + }, + "get_auto_update_info": { + "enable": false, + "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": 46, + "default_states": { + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 221014 Rel.112003", + "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": "48-22-54-00-00-00", + "model": "S500D", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheat_status": "normal", + "region": "Pacific/Honolulu", + "rssi": -31, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -600, + "type": "SMART.TAPOSWITCH" + }, + "get_device_time": { + "region": "Pacific/Honolulu", + "time_diff": -600, + "timestamp": 1706136515 + }, + "get_device_usage": { + "time_usage": { + "past30": 0, + "past7": 0, + "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": 786432, + "fw_ver": "1.1.0 Build 230906 Rel.141935", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2023-10-07", + "release_note": "Modifications and Bug Fixes:\n1. Enhanced stability and performance.\n2. Enhanced local communication security.", + "type": 2 + }, + "get_led_info": { + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 426, + "night_mode_type": "sunrise_sunset", + "start_time": 1093, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "off_state": { + "duration": 2, + "enable": true + }, + "on_state": { + "duration": 3, + "enable": true + } + }, + "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": "wpa_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "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": "S500D", + "device_type": "SMART.TAPOSWITCH" + } + } +} 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 new file mode 100644 index 000000000..340bd3a1e --- /dev/null +++ b/tests/fixtures/smart/S505(US)_1.0_1.0.2.json @@ -0,0 +1,312 @@ +{ + "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": "matter", + "ver_code": 1 + }, + { + "id": "delay_action", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "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, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "get_auto_update_info": { + "enable": false, + "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.0.2 Build 230313 Rel.101023", + "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": "S505", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheated": false, + "region": "Pacific/Honolulu", + "rssi": -37, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -600, + "type": "SMART.TAPOSWITCH" + }, + "get_device_time": { + "region": "Pacific/Honolulu", + "time_diff": -600, + "timestamp": 1706137970 + }, + "get_device_usage": { + "time_usage": { + "past30": 0, + "past7": 0, + "today": 0 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 786432, + "fw_ver": "1.1.0 Build 231024 Rel.201030", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2023-11-24", + "release_note": "Modifications and Bug Fixes:\n1. Enhanced stability and performance.\n2. Enhanced local communication security.\n3. Fixed some minor bugs.", + "type": 2 + }, + "get_led_info": { + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 426, + "night_mode_type": "sunrise_sunset", + "start_time": 1093, + "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": "wpa_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "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": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "S505", + "device_type": "SMART.TAPOSWITCH" + } + } +} 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 new file mode 100644 index 000000000..0c990d758 --- /dev/null +++ b/tests/fixtures/smart/S505D(US)_1.0_1.1.0.json @@ -0,0 +1,265 @@ +{ + "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": "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, + "enable": false, + "rule_list": [] + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 1 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "switch_s500d", + "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": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.0 Build 231024 Rel.201030", + "has_set_location_info": false, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "48-22-54-00-00-00", + "model": "S505D", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheat_status": "normal", + "region": "America/Chicago", + "rssi": -39, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -360, + "type": "SMART.TAPOSWITCH" + }, + "get_device_time": { + "region": "America/Chicago", + "time_diff": -360, + "timestamp": 952082825 + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_led_info": { + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 420, + "night_mode_type": "sunrise_sunset", + "start_time": 1140, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_matter_setup_info": { + "setup_code": "00000000000", + "setup_payload": "00:-00000000000000.000" + }, + "get_next_event": {}, + "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": [], + "start_index": 0, + "sum": 0, + "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": "S505D", + "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 new file mode 100644 index 000000000..8d0964b36 --- /dev/null +++ b/tests/fixtures/smart/TP15(US)_1.0_1.0.3.json @@ -0,0 +1,298 @@ +{ + "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": "matter", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "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, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "get_auto_update_info": { + "enable": false, + "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": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.3 Build 230112 Rel.124621", + "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": "5C-62-8B-00-00-00", + "model": "TP15", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 83, + "overheated": false, + "region": "America/Chicago", + "rssi": -53, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -360, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "America/Chicago", + "time_diff": -360, + "timestamp": 1706719971 + }, + "get_device_usage": { + "time_usage": { + "past30": 0, + "past7": 0, + "today": 0 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 786432, + "fw_ver": "1.1.0 Build 231009 Rel.155831", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2023-11-24", + "release_note": "Modifications and Bug Fixes:\n1. Enhanced local communication security.\n2. Improved Matter setup process.\n3. Optimized stability and performance.\n4. Fixed some minor bugs.", + "type": 2 + }, + "get_led_info": { + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 433, + "night_mode_type": "sunrise_sunset", + "start_time": 1079, + "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": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "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": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "TP15", + "device_type": "SMART.TAPOPLUG" + } + } +} 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 new file mode 100644 index 000000000..b91654149 --- /dev/null +++ b/tests/fixtures/smart/TP25(US)_1.0_1.0.2.json @@ -0,0 +1,426 @@ +{ + "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": "matter", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "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, + "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": "overheat_protection", + "ver_code": 1 + } + ], + "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": "overheat_protection", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_child_device_list": { + "child_device_list": [ + { + "avatar": "plug", + "bind_count": 1, + "category": "plug.powerstrip.sub-plug", + "default_states": { + "type": "last_states" + }, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.2 Build 230206 Rel.095245", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude": 0, + "longitude": 0, + "mac": "3C52A1000000", + "model": "TP25", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 1, + "region": "America/Chicago", + "slot_number": 2, + "status_follow_edge": true, + "type": "SMART.TAPOPLUG" + }, + { + "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.0.2 Build 230206 Rel.095245", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "latitude": 0, + "longitude": 0, + "mac": "3C52A1000000", + "model": "TP25", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 63159, + "original_device_id": "0000000000000000000000000000000000000000", + "overheat_status": "normal", + "position": 2, + "region": "America/Chicago", + "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.2 Build 230206 Rel.095245", + "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": "TP25", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "region": "America/Chicago", + "rssi": -39, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -360, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "America/Chicago", + "time_diff": -360, + "timestamp": 1706812337 + }, + "get_device_usage": { + "time_usage": { + "past30": 1, + "past7": 1, + "today": 0 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 786432, + "fw_ver": "1.1.0 Build 231019 Rel.173739", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2023-11-24", + "release_note": "Modifications and Bug Fixes:\n1. Enhanced local communication security.\n2. Improved Matter setup process.\n3. Optimized stability and performance.\n4. Fixed some minor bugs.", + "type": 2 + }, + "get_led_info": { + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 432, + "night_mode_type": "sunrise_sunset", + "start_time": 1080, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "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": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "matter", + "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": "TP25", + "device_type": "SMART.TAPOPLUG" + } + } +} 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/KE100(EU)_1.0_2.4.0.json b/tests/fixtures/smart/child/KE100(EU)_1.0_2.4.0.json new file mode 100644 index 000000000..cd3a241ee --- /dev/null +++ b/tests/fixtures/smart/child/KE100(EU)_1.0_2.4.0.json @@ -0,0 +1,170 @@ +{ + "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": "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": "frost_protection", + "ver_code": 1 + }, + { + "id": "child_protection", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "temp_control", + "ver_code": 1 + }, + { + "id": "remove_scale", + "ver_code": 1 + }, + { + "id": "progress_calibration", + "ver_code": 1 + }, + { + "id": "early_start", + "ver_code": 1 + }, + { + "id": "temp_record", + "ver_code": 1 + }, + { + "id": "screen_setting", + "ver_code": 1 + }, + { + "id": "night_mode", + "ver_code": 1 + }, + { + "id": "smart_control_schedule", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature_correction", + "ver_code": 1 + }, + { + "id": "window_open_detect", + "ver_code": 2 + }, + { + "id": "shutdown_mode", + "ver_code": 1 + } + ] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "", + "battery_percentage": 100, + "bind_count": 5, + "category": "subg.trv", + "child_protection": false, + "current_temp": 22.9, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "frost_protection_on": false, + "fw_ver": "2.4.0 Build 230804 Rel.193040", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -113, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1713888871, + "location": "", + "mac": "A842A1000000", + "max_control_temp": 30, + "min_control_temp": 5, + "model": "KE100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/Berlin", + "rssi": -7, + "signal_level": 3, + "specs": "EU", + "status": "online", + "target_temp": 23.0, + "temp_offset": 0, + "temp_unit": "celsius", + "trv_states": [ + "heating" + ], + "type": "SMART.KASAENERGY" + }, + "get_fw_download_state": { + "cloud_cache_seconds": 1, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_ver": "2.8.0 Build 240202 Rel.135229", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2024-02-05", + "release_note": "Modifications and Bug Fixes:\n1. Optimized the noise issue in some cases.\n2. Fixed some minor bugs.", + "type": 2 + } +} diff --git a/tests/fixtures/smart/child/KE100(EU)_1.0_2.8.0.json b/tests/fixtures/smart/child/KE100(EU)_1.0_2.8.0.json new file mode 100644 index 000000000..14bb10c97 --- /dev/null +++ b/tests/fixtures/smart/child/KE100(EU)_1.0_2.8.0.json @@ -0,0 +1,171 @@ +{ + "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": "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": "frost_protection", + "ver_code": 1 + }, + { + "id": "child_protection", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "temp_control", + "ver_code": 1 + }, + { + "id": "remove_scale", + "ver_code": 1 + }, + { + "id": "shutdown_mode", + "ver_code": 1 + }, + { + "id": "progress_calibration", + "ver_code": 1 + }, + { + "id": "early_start", + "ver_code": 1 + }, + { + "id": "temp_record", + "ver_code": 1 + }, + { + "id": "screen_setting", + "ver_code": 1 + }, + { + "id": "night_mode", + "ver_code": 1 + }, + { + "id": "smart_control_schedule", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature_correction", + "ver_code": 1 + }, + { + "id": "window_open_detect", + "ver_code": 2 + } + ] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "kasa_trv", + "battery_percentage": 100, + "bind_count": 1, + "category": "subg.trv", + "child_protection": false, + "current_temp": 19.2, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_7", + "frost_protection_on": false, + "fw_ver": "2.8.0 Build 240202 Rel.135229", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -121, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1705684116, + "location": "", + "mac": "F0A731000000", + "max_control_temp": 30, + "min_control_temp": 5, + "model": "KE100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/London", + "rssi": -73, + "signal_level": 2, + "specs": "EU", + "status": "online", + "target_temp": 21.0, + "temp_offset": 0, + "temp_unit": "celsius", + "trv_states": [ + "heating" + ], + "type": "SMART.KASAENERGY" + }, + "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": "2.8.0 Build 240202 Rel.135229", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + } +} diff --git a/tests/fixtures/smart/child/KE100(UK)_1.0_2.8.0.json b/tests/fixtures/smart/child/KE100(UK)_1.0_2.8.0.json new file mode 100644 index 000000000..199d572a6 --- /dev/null +++ b/tests/fixtures/smart/child/KE100(UK)_1.0_2.8.0.json @@ -0,0 +1,171 @@ +{ + "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": "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": "frost_protection", + "ver_code": 1 + }, + { + "id": "child_protection", + "ver_code": 1 + }, + { + "id": "temperature", + "ver_code": 1 + }, + { + "id": "temp_control", + "ver_code": 1 + }, + { + "id": "remove_scale", + "ver_code": 1 + }, + { + "id": "shutdown_mode", + "ver_code": 1 + }, + { + "id": "progress_calibration", + "ver_code": 1 + }, + { + "id": "early_start", + "ver_code": 1 + }, + { + "id": "temp_record", + "ver_code": 1 + }, + { + "id": "screen_setting", + "ver_code": 1 + }, + { + "id": "night_mode", + "ver_code": 1 + }, + { + "id": "smart_control_schedule", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "battery_detect", + "ver_code": 1 + }, + { + "id": "temperature_correction", + "ver_code": 1 + }, + { + "id": "window_open_detect", + "ver_code": 2 + } + ] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "kasa_trv", + "battery_percentage": 100, + "bind_count": 1, + "category": "subg.trv", + "child_protection": false, + "current_temp": 20.1, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3", + "frost_protection_on": false, + "fw_ver": "2.8.0 Build 240202 Rel.135229", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -117, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1705677078, + "location": "", + "mac": "F0A731000000", + "max_control_temp": 30, + "min_control_temp": 5, + "model": "KE100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/London", + "rssi": -45, + "signal_level": 3, + "specs": "UK", + "status": "online", + "target_temp": 21.0, + "temp_offset": 0, + "temp_unit": "celsius", + "trv_states": [ + "heating" + ], + "type": "SMART.KASAENERGY" + }, + "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": "2.8.0 Build 240202 Rel.135229", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + } +} diff --git a/tests/fixtures/smart/child/S200B(EU)_1.0_1.11.0.json b/tests/fixtures/smart/child/S200B(EU)_1.0_1.11.0.json new file mode 100644 index 000000000..9df75fd76 --- /dev/null +++ b/tests/fixtures/smart/child/S200B(EU)_1.0_1.11.0.json @@ -0,0 +1,115 @@ +{ + "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": "double_click", + "ver_code": 1 + } + ] + }, + "get_auto_update_info": -1001, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "button", + "bind_count": 2, + "category": "subg.trigger.button", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "fw_ver": "1.11.0 Build 230821 Rel.113553", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -120, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1714016798, + "mac": "202351000000", + "model": "S200B", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/London", + "report_interval": 16, + "rssi": -55, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "type": "SMART.TAPOSENSOR" + }, + "get_device_time": -1001, + "get_device_usage": -1001, + "get_double_click_info": { + "enable": false + }, + "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.12.0 Build 231121 Rel.092444", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2024-04-02", + "release_note": "Modifications and Bug Fixes:\n1. Optimized low battery notification.\n2. Fixed some minor bugs.", + "type": 2 + }, + "get_trigger_logs": { + "logs": [], + "start_id": 0, + "sum": 0 + }, + "qs_component_nego": -1001 +} diff --git a/tests/fixtures/smart/child/S200B(US)_1.0_1.12.0.json b/tests/fixtures/smart/child/S200B(US)_1.0_1.12.0.json new file mode 100644 index 000000000..fa3b7c136 --- /dev/null +++ b/tests/fixtures/smart/child/S200B(US)_1.0_1.12.0.json @@ -0,0 +1,116 @@ +{ + "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": "double_click", + "ver_code": 1 + } + ] + }, + "get_auto_update_info": -1001, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "button", + "bind_count": 1, + "category": "subg.trigger.button", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_5", + "fw_ver": "1.12.0 Build 231121 Rel.092508", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -113, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1724636886, + "mac": "98254A000000", + "model": "S200B", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Australia/Canberra", + "report_interval": 16, + "rssi": -56, + "signal_level": 3, + "specs": "US", + "status": "online", + "status_follow_edge": false, + "type": "SMART.TAPOSENSOR" + }, + "get_device_time": -1001, + "get_device_usage": -1001, + "get_double_click_info": { + "enable": false + }, + "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 231121 Rel.092508", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_trigger_logs": { + "logs": [], + "start_id": 0, + "sum": 0 + }, + "qs_component_nego": -1001 +} diff --git a/tests/fixtures/smart/child/S200D(EU)_1.0_1.11.0.json b/tests/fixtures/smart/child/S200D(EU)_1.0_1.11.0.json new file mode 100644 index 000000000..3ee20e537 --- /dev/null +++ b/tests/fixtures/smart/child/S200D(EU)_1.0_1.11.0.json @@ -0,0 +1,504 @@ +{ + "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": "double_click", + "ver_code": 1 + } + ] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "button", + "bind_count": 1, + "category": "subg.trigger.button", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "fw_ver": "1.11.0 Build 230821 Rel.113553", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -117, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1728469002, + "mac": "6083E7000000", + "model": "S200D", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "CEST", + "report_interval": 16, + "rssi": -42, + "signal_level": 3, + "specs": "EU", + "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_ver": "1.12.0 Build 231121 Rel.092444", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2024-06-06", + "release_note": "Modifications and Bug Fixes:\n1. Optimized low battery notification.\n2. Fixed some minor bugs.", + "type": 1 + }, + "get_temp_humidity_records": { + "local_time": 1728469073, + "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, + -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 + ], + "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, + -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 + ], + "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, + -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 + ], + "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, + -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 + ], + "temp_unit": "celsius" + }, + "get_trigger_logs": { + "logs": [], + "start_id": 0, + "sum": 0 + } +} diff --git a/tests/fixtures/smart/child/S200D(EU)_1.0_1.12.0.json b/tests/fixtures/smart/child/S200D(EU)_1.0_1.12.0.json new file mode 100644 index 000000000..0ba6e17b0 --- /dev/null +++ b/tests/fixtures/smart/child/S200D(EU)_1.0_1.12.0.json @@ -0,0 +1,536 @@ +{ + "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": "double_click", + "ver_code": 1 + } + ] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "button", + "bind_count": 1, + "category": "subg.trigger.button", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "fw_ver": "1.12.0 Build 231121 Rel.092444", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -120, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1728469002, + "mac": "6083E7000000", + "model": "S200D", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "CEST", + "report_interval": 16, + "rssi": -42, + "signal_level": 3, + "specs": "EU", + "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 231121 Rel.092444", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_temp_humidity_records": { + "local_time": 1728470630, + "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, + -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 + ], + "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, + -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 + ], + "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, + -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 + ], + "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, + -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 + ], + "temp_unit": "celsius" + }, + "get_trigger_logs": { + "logs": [ + { + "event": "singleClick", + "eventId": "601a2fbd-f4d0-ca4f-85e0-dacf4d0ca4f8", + "id": 99, + "timestamp": 1728469787 + }, + { + "event": "singleClick", + "eventId": "d0b50fda-30c5-37c6-3646-fea20c537c63", + "id": 98, + "timestamp": 1728469781 + }, + { + "event": "singleClick", + "eventId": "f830dc2a-d920-5466-f7b7-1f436dfab990", + "id": 97, + "timestamp": 1728469780 + }, + { + "event": "doubleClick", + "eventId": "8b6719ae-7d1c-acf4-d846-89e6d1cacf4d", + "id": 96, + "timestamp": 1728469776 + }, + { + "event": "singleClick", + "eventId": "913fe08f-b823-66c4-9db9-2bea82366c49", + "id": 95, + "timestamp": 1728469774 + } + ], + "start_id": 99, + "sum": 51 + } +} 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(EU)_1.0_1.12.0.json b/tests/fixtures/smart/child/T100(EU)_1.0_1.12.0.json new file mode 100644 index 000000000..0103fbdcf --- /dev/null +++ b/tests/fixtures/smart/child/T100(EU)_1.0_1.12.0.json @@ -0,0 +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": "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": false, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3", + "fw_ver": "1.12.0 Build 230512 Rel.103011", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -118, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1703860126, + "mac": "E4FAC4000000", + "model": "T100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/Berlin", + "report_interval": 60, + "rssi": -73, + "signal_level": 2, + "specs": "EU", + "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.103011", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_temp_humidity_records": { + "local_time": 1721645923, + "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, + -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 + ], + "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, + -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 + ], + "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, + -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 + ], + "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, + -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 + ], + "temp_unit": "celsius" + }, + "get_trigger_logs": { + "logs": [ + { + "event": "motion", + "eventId": "f883b62c-e18f-30ef-883b-62ce18f30ef8", + "id": 28763, + "timestamp": 1721643865 + }, + { + "event": "motion", + "eventId": "c5157545-55d5-157d-4157-54555d5157d4", + "id": 28748, + "timestamp": 1721630821 + }, + { + "event": "motion", + "eventId": "1b587961-edab-08d1-b587-961edab08d1b", + "id": 28746, + "timestamp": 1721629441 + }, + { + "event": "motion", + "eventId": "8ac5e271-3894-c269-bc5e-2713894c269b", + "id": 28738, + "timestamp": 1721622777 + }, + { + "event": "motion", + "eventId": "1ef8037e-c097-bc21-ef80-37ec097bc21e", + "id": 28722, + "timestamp": 1721596432 + } + ], + "start_id": 28763, + "sum": 86 + } +} 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/T110(EU)_1.0_1.8.0.json b/tests/fixtures/smart/child/T110(EU)_1.0_1.8.0.json new file mode 100644 index 000000000..0393e18bf --- /dev/null +++ b/tests/fixtures/smart/child/T110(EU)_1.0_1.8.0.json @@ -0,0 +1,526 @@ +{ + "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 + } + ] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "sensor_t110", + "bind_count": 1, + "category": "subg.trigger.contact-sensor", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "fw_ver": "1.8.0 Build 220728 Rel.160024", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -113, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1714661626, + "mac": "E4FAC4000000", + "model": "T110", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "open": false, + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/Berlin", + "report_interval": 16, + "rssi": -54, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "type": "SMART.TAPOSENSOR" + }, + "get_fw_download_state": { + "cloud_cache_seconds": 1, + "download_progress": 30, + "reboot_time": 5, + "status": 4, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_ver": "1.9.0 Build 230704 Rel.154531", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2023-10-30", + "release_note": "Modifications and Bug Fixes:\n1. Reduced power consumption.\n2. Fixed some minor bugs.", + "type": 2 + }, + "get_temp_humidity_records": { + "local_time": 1714681046, + "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, + -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 + ], + "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, + -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 + ], + "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, + -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 + ], + "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, + -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 + ], + "temp_unit": "celsius" + }, + "get_trigger_logs": { + "logs": [ + { + "event": "close", + "eventId": "8140289c-c66b-bdd6-63b9-542299442299", + "id": 4, + "timestamp": 1714661714 + }, + { + "event": "open", + "eventId": "fb4e1439-2f2c-a5e1-c35a-9e7c0d35a1e3", + "id": 3, + "timestamp": 1714661710 + }, + { + "event": "close", + "eventId": "ddee7733-1180-48ac-56a3-512018048ac5", + "id": 2, + "timestamp": 1714661657 + }, + { + "event": "open", + "eventId": "ab80951f-da38-49f9-21c5-bf025c7b606d", + "id": 1, + "timestamp": 1714661638 + } + ], + "start_id": 4, + "sum": 4 + } +} diff --git a/tests/fixtures/smart/child/T110(EU)_1.0_1.9.0.json b/tests/fixtures/smart/child/T110(EU)_1.0_1.9.0.json new file mode 100644 index 000000000..c11964494 --- /dev/null +++ b/tests/fixtures/smart/child/T110(EU)_1.0_1.9.0.json @@ -0,0 +1,511 @@ +{ + "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 + } + ] + }, + "get_connect_cloud_state": { + "status": 1 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "", + "bind_count": 1, + "category": "subg.trigger.contact-sensor", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "fw_ver": "1.9.0 Build 230704 Rel.154531", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -104, + "jamming_signal_level": 2, + "lastOnboardingTimestamp": 1727367924, + "mac": "74FECE000000", + "model": "T110", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "open": false, + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "CEST", + "report_interval": 16, + "rssi": -20, + "signal_level": 3, + "specs": "EU", + "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_temp_humidity_records": { + "local_time": 1727368055, + "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, + -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 + ], + "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, + -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 + ], + "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, + -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 + ], + "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, + -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 + ], + "temp_unit": "celsius" + }, + "get_trigger_logs": { + "logs": [ + { + "event": "close", + "eventId": "1b85ca6d-3e97-c361-b0d0-683c1683c9e4", + "id": 4, + "timestamp": 1727368053 + }, + { + "event": "keepOpen", + "eventId": "8258b54b-bc4f-3e06-1a14-9b543b0c1f9e", + "id": 3, + "timestamp": 1727367990 + }, + { + "event": "open", + "eventId": "a3620a36-a86f-8cf5-4113-b26a86f8cf54", + "id": 2, + "timestamp": 1727367930 + } + ], + "start_id": 4, + "sum": 3 + } +} diff --git a/tests/fixtures/smart/child/T110(US)_1.0_1.9.0.json b/tests/fixtures/smart/child/T110(US)_1.0_1.9.0.json new file mode 100644 index 000000000..43dbf731e --- /dev/null +++ b/tests/fixtures/smart/child/T110(US)_1.0_1.9.0.json @@ -0,0 +1,110 @@ +{ + "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 + } + ] + }, + "get_auto_update_info": -1001, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "outdoor", + "bind_count": 1, + "category": "subg.trigger.contact-sensor", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3", + "fw_ver": "1.9.0 Build 230704 Rel.154559", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -113, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1724635267, + "mac": "A86E84000000", + "model": "T110", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "open": false, + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Australia/Canberra", + "report_interval": 16, + "rssi": -56, + "signal_level": 3, + "specs": "US", + "status": "online", + "status_follow_edge": false, + "type": "SMART.TAPOSENSOR" + }, + "get_device_time": -1001, + "get_device_usage": -1001, + "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 230704 Rel.154559", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_trigger_logs": { + "logs": [], + "start_id": 0, + "sum": 0 + }, + "qs_component_nego": -1001 +} diff --git a/tests/fixtures/smart/child/T300(EU)_1.0_1.7.0.json b/tests/fixtures/smart/child/T300(EU)_1.0_1.7.0.json new file mode 100644 index 000000000..a08cda11a --- /dev/null +++ b/tests/fixtures/smart/child/T300(EU)_1.0_1.7.0.json @@ -0,0 +1,540 @@ +{ + "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": "sensor_alarm", + "ver_code": 1 + } + ] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "sensor_t300", + "battery_percentage": 100, + "bind_count": 1, + "category": "subg.trigger.water-leak-sensor", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_6", + "fw_ver": "1.7.0 Build 230628 Rel.194748", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "in_alarm": false, + "jamming_rssi": -119, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1728470353, + "mac": "A86E84000000", + "model": "T300", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "CEST", + "report_interval": 16, + "rssi": -44, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "trigger_timestamp": 1728480717, + "type": "SMART.TAPOSENSOR", + "water_leak_status": "water_dry" + }, + "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.7.0 Build 230628 Rel.194748", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_temp_humidity_records": { + "local_time": 1729248928, + "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, + -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 + ], + "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, + -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 + ], + "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, + -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 + ], + "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, + -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 + ], + "temp_unit": "celsius" + }, + "get_trigger_logs": { + "logs": [ + { + "event": "waterDry", + "eventId": "d595356d-4953-5654-d59d-b92b6aca9ab2", + "id": 114, + "timestamp": 1728480717 + }, + { + "event": "waterLeak", + "eventId": "c43fc234-4ff2-ac03-d4bf-0254ff2ac03d", + "id": 113, + "timestamp": 1728480714 + }, + { + "event": "waterDry", + "eventId": "3e68c39e-b027-e405-7d41-d714fd81bfa8", + "id": 112, + "timestamp": 1728471129 + }, + { + "event": "waterLeak", + "eventId": "0e8743a9-d46a-bdde-67bb-d562b9542219", + "id": 111, + "timestamp": 1728471123 + }, + { + "event": "waterDry", + "eventId": "97708bf6-4817-b06b-0ebc-ed45917b06b0", + "id": 110, + "timestamp": 1728471106 + } + ], + "start_id": 114, + "sum": 14 + } +} 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 new file mode 100644 index 000000000..0d9108eef --- /dev/null +++ b/tests/fixtures/smart/child/T310(EU)_1.0_1.5.0.json @@ -0,0 +1,530 @@ +{ + "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": "sensor_t310", + "bind_count": 1, + "category": "subg.trigger.temp-hmdt-sensor", + "current_humidity": 54, + "current_humidity_exception": 0, + "current_temp": 19.3, + "current_temp_exception": 0, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_10", + "fw_ver": "1.5.0 Build 230105 Rel.150707", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -120, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1706789728, + "mac": "E4FAC4000000", + "model": "T310", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/London", + "report_interval": 16, + "rssi": -81, + "signal_level": 1, + "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_size": 0, + "fw_ver": "1.5.0 Build 230105 Rel.150707", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_temp_humidity_records": { + "local_time": 1713550233, + "past24h_humidity": [ + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 61, + 61, + 61, + 61, + 61, + 61, + 61, + 61, + 61, + 61, + 61, + 61, + 62, + 61, + 61, + 62, + 61, + 60, + 61, + 61, + 61, + 61, + 61, + 61, + 61, + 61, + 61, + 61, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 62, + 63, + 63, + 63, + 64, + 63, + 63, + 63, + 63, + 62, + 63, + 63, + 62, + 62, + 62, + 62, + 62, + 61, + 62, + 61, + 61, + 61, + 61, + 61, + 61, + 60, + 61, + 64, + 64, + 61, + 61, + 63, + 60, + 60, + 60, + 60, + 59, + 59, + 59, + 59, + 59, + 58, + 58, + 58, + 57, + 55 + ], + "past24h_humidity_exception": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 2, + 1, + 1, + 2, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 3, + 3, + 3, + 4, + 3, + 3, + 3, + 3, + 2, + 3, + 3, + 2, + 2, + 2, + 2, + 2, + 1, + 2, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 4, + 4, + 1, + 1, + 3, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "past24h_temp": [ + 175, + 175, + 174, + 174, + 173, + 172, + 172, + 171, + 170, + 169, + 169, + 167, + 167, + 166, + 165, + 164, + 163, + 163, + 162, + 162, + 162, + 162, + 163, + 163, + 162, + 162, + 161, + 160, + 159, + 159, + 159, + 159, + 158, + 158, + 159, + 159, + 158, + 159, + 159, + 159, + 159, + 159, + 159, + 159, + 159, + 159, + 158, + 158, + 158, + 158, + 158, + 158, + 159, + 159, + 160, + 161, + 161, + 162, + 162, + 162, + 162, + 162, + 163, + 163, + 166, + 168, + 170, + 172, + 174, + 175, + 176, + 177, + 179, + 181, + 183, + 184, + 185, + 187, + 189, + 190, + 190, + 193, + 194, + 194, + 194, + 194, + 194, + 194, + 195, + 195, + 195, + 196, + 196, + 196, + 195, + 193 + ], + "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, + -1, + -1, + -1, + -1, + -2, + -2, + -1, + -1, + -2, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -2, + -2, + -2, + -2, + -2, + -2, + -1, + -1, + 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 + ], + "temp_unit": "celsius" + }, + "get_trigger_logs": { + "logs": [], + "start_id": 0, + "sum": 0 + } +} 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 new file mode 100644 index 000000000..c06ff49f1 --- /dev/null +++ b/tests/fixtures/smart/child/T310(US)_1.0_1.5.0.json @@ -0,0 +1,530 @@ +{ + "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": "sensor_t310", + "bind_count": 1, + "category": "subg.trigger.temp-hmdt-sensor", + "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", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -108, + "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": -56, + "signal_level": 3, + "specs": "US", + "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_size": 0, + "fw_ver": "1.5.0", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "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 + } +} 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 new file mode 100644 index 000000000..a9fd67e38 --- /dev/null +++ b/tests/fixtures/smart/child/T315(EU)_1.0_1.7.0.json @@ -0,0 +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 + } +} diff --git a/tests/fixtures/smart/child/T315(US)_1.0_1.8.0.json b/tests/fixtures/smart/child/T315(US)_1.0_1.8.0.json new file mode 100644 index 000000000..7a557b8c7 --- /dev/null +++ b/tests/fixtures/smart/child/T315(US)_1.0_1.8.0.json @@ -0,0 +1,139 @@ +{ + "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_auto_update_info": -1001, + "get_connect_cloud_state": { + "status": 0 + }, + "get_device_info": { + "at_low_battery": false, + "avatar": "sensor_t315", + "battery_percentage": 100, + "bind_count": 1, + "category": "subg.trigger.temp-hmdt-sensor", + "current_humidity": 51, + "current_humidity_exception": 0, + "current_temp": 21.5, + "current_temp_exception": 0, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "fw_ver": "1.8.0 Build 230921 Rel.091519", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -113, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1724637369, + "mac": "202351000000", + "model": "T315", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Australia/Canberra", + "report_interval": 16, + "rssi": -44, + "signal_level": 3, + "specs": "US", + "status": "online", + "status_follow_edge": false, + "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, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.8.0 Build 230921 Rel.091519", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_trigger_logs": { + "logs": [], + "start_id": 0, + "sum": 0 + }, + "qs_component_nego": -1001 +} 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 new file mode 100644 index 000000000..609c46bec --- /dev/null +++ b/tests/fixtures/smartcam/C210(EU)_2.0_1.4.2.json @@ -0,0 +1,965 @@ +{ + "discovery_result": { + "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": { + "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 + } + ] + } + }, + "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": "2024-10-24 12:49:09", + "seconds_from_1970": 1729770549 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "3", + "rssiValue": -62, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + "digital_sensitivity": "50", + "enabled": "on", + "non_vehicle_enabled": "off", + "people_enabled": "off", + "sensitivity": "medium", + "vehicle_enabled": "off" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "Home", + "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_id": "00000000000000000000000000000000", + "hw_version": "2.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "40-AE-30-00-00-00", + "manufacturer_name": "TP-LINK", + "mobile_access": "0", + "oem_id": "00000000000000000000000000000000", + "region": "EU", + "sw_version": "1.4.2 Build 240829 Rel.54953n" + } + } + }, + "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": "1729264456", + "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": "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": "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": "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": "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" + ] + } + } + }, + "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": "50" + } + } + }, + "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": "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": "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/Berlin" + } + } + }, + "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": "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", + "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", + "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" + } + } + } + } +} 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 new file mode 100644 index 000000000..d4de5b9f2 --- /dev/null +++ b/tests/fixtures/smartcam/C210(EU)_2.0_1.4.3.json @@ -0,0 +1,1003 @@ +{ + "discovery_result": { + "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": { + "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 + } + ] + } + }, + "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": "2024-12-15 11:28:40", + "seconds_from_1970": 1734262120 + } + } + }, + "getConnectStatus": { + "onboarding": { + "get_connect_status": { + "status": 0 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "3", + "rssiValue": -61, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + "getDetectionConfig": { + "motion_detection": { + "motion_det": { + "digital_sensitivity": "50", + "enabled": "on", + "non_vehicle_enabled": "off", + "people_enabled": "off", + "sensitivity": "medium", + "vehicle_enabled": "off" + } + } + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "Home", + "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_id": "00000000000000000000000000000000", + "hw_version": "2.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "40-AE-30-00-00-00", + "manufacturer_name": "TP-LINK", + "mobile_access": "0", + "oem_id": "00000000000000000000000000000000", + "region": "EU", + "sw_version": "1.4.3 Build 241010 Rel.33858n" + } + } + }, + "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": "1733422805", + "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": "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": "on" + } + } + }, + "getLensMaskConfig": { + "lens_mask": { + "lens_mask_info": { + "enabled": "on" + } + } + }, + "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": "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" + ] + } + } + }, + "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": "50" + } + } + }, + "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": "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": "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", + "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": "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", + "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", + "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": 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 new file mode 100644 index 000000000..4ef99fae2 --- /dev/null +++ b/tests/fixtures/smartcam/H200(EU)_1.0_1.3.2.json @@ -0,0 +1,500 @@ +{ + "discovery_result": { + "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": {}, + "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 + } + ] + } + }, + "getChildDeviceComponentList": { + "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": "double_click", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1" + } + ], + "start_index": 0, + "sum": 1 + }, + "getChildDeviceList": { + "child_device_list": [ + { + "at_low_battery": false, + "avatar": "button", + "bind_count": 2, + "category": "subg.trigger.button", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "fw_ver": "1.11.0 Build 230821 Rel.113553", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -119, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1714016798, + "mac": "202351000000", + "model": "S200B", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Europe/London", + "report_interval": 16, + "rssi": -60, + "signal_level": 3, + "specs": "EU", + "status": "online", + "status_follow_edge": false, + "type": "SMART.TAPOSENSOR" + } + ], + "start_index": 0, + "sum": 1 + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "1984-10-21 23:48:23", + "seconds_from_1970": 467246903 + } + } + }, + "getConnectStatus": { + "onboarding": { + "get_connect_status": { + "current_ssid": "", + "err_code": 0, + "status": 0 + } + } + }, + "getConnectionType": { + "link_type": "ethernet" + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "gateway", + "bind_status": true, + "child_num": 0, + "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, + "longitude": 0, + "mac": "A8-6E-84-00-00-00", + "need_sync_sha1_password": 0, + "oem_id": "00000000000000000000000000000000", + "product_name": "Tapo Smart Hub", + "region": "EU", + "status": "configured", + "sw_version": "1.3.2 Build 20240424 rel.75425" + }, + "info": { + "avatar": "gateway", + "bind_status": true, + "child_num": 0, + "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, + "longitude": 0, + "mac": "A8-6E-84-00-00-00", + "need_sync_sha1_password": 0, + "oem_id": "00000000000000000000000000000000", + "product_name": "Tapo Smart Hub", + "region": "EU", + "status": "configured", + "sw_version": "1.3.2 Build 20240424 rel.75425" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + "enabled": "off", + "random_range": 120, + "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:000000-000000000000" + }, + "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": 300, + "siren_type": "Doorbell Ring 1", + "volume": "1" + }, + "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+00:00", + "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 new file mode 100644 index 000000000..26c037936 --- /dev/null +++ b/tests/fixtures/smartcam/H200(US)_1.0_1.3.6.json @@ -0,0 +1,788 @@ +{ + "discovery_result": { + "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": {}, + "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": [ + { + "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": "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_2" + }, + { + "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 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3" + }, + { + "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": "double_click", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_4" + }, + { + "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": "double_click", + "ver_code": 1 + } + ], + "device_id": "SCRUBBED_CHILD_DEVICE_ID_5" + } + ], + "start_index": 0, + "sum": 5 + }, + "getChildDeviceList": { + "child_device_list": [ + { + "at_low_battery": false, + "avatar": "sensor_t310", + "bind_count": 1, + "category": "subg.trigger.temp-hmdt-sensor", + "current_humidity": 56, + "current_humidity_exception": 0, + "current_temp": 21.7, + "current_temp_exception": 0, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_1", + "fw_ver": "1.5.0 Build 230105 Rel.180832", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -111, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1724637745, + "mac": "F0A731000000", + "model": "T310", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Australia/Canberra", + "report_interval": 16, + "rssi": -43, + "signal_level": 3, + "specs": "US", + "status": "online", + "status_follow_edge": false, + "temp_unit": "celsius", + "type": "SMART.TAPOSENSOR" + }, + { + "at_low_battery": false, + "avatar": "sensor_t315", + "battery_percentage": 100, + "bind_count": 1, + "category": "subg.trigger.temp-hmdt-sensor", + "current_humidity": 58, + "current_humidity_exception": 0, + "current_temp": 21.6, + "current_temp_exception": 0, + "device_id": "SCRUBBED_CHILD_DEVICE_ID_2", + "fw_ver": "1.8.0 Build 230921 Rel.091519", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -107, + "jamming_signal_level": 2, + "lastOnboardingTimestamp": 1724637369, + "mac": "202351000000", + "model": "T315", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Australia/Canberra", + "report_interval": 16, + "rssi": -42, + "signal_level": 3, + "specs": "US", + "status": "online", + "status_follow_edge": false, + "temp_unit": "celsius", + "type": "SMART.TAPOSENSOR" + }, + { + "at_low_battery": false, + "avatar": "outdoor", + "bind_count": 1, + "category": "subg.trigger.contact-sensor", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_3", + "fw_ver": "1.9.0 Build 230704 Rel.154559", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -112, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1724635267, + "mac": "A86E84000000", + "model": "T110", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "open": false, + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Australia/Canberra", + "report_interval": 16, + "rssi": -47, + "signal_level": 3, + "specs": "US", + "status": "online", + "status_follow_edge": false, + "type": "SMART.TAPOSENSOR" + }, + { + "at_low_battery": false, + "avatar": "button", + "bind_count": 1, + "category": "subg.trigger.button", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_4", + "fw_ver": "1.12.0 Build 231121 Rel.092508", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -115, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1724636047, + "mac": "3C52A1000000", + "model": "S200B", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Australia/Canberra", + "report_interval": 16, + "rssi": -45, + "signal_level": 3, + "specs": "US", + "status": "online", + "status_follow_edge": false, + "type": "SMART.TAPOSENSOR" + }, + { + "at_low_battery": false, + "avatar": "button", + "bind_count": 1, + "category": "subg.trigger.button", + "device_id": "SCRUBBED_CHILD_DEVICE_ID_5", + "fw_ver": "1.12.0 Build 231121 Rel.092508", + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "jamming_rssi": -114, + "jamming_signal_level": 1, + "lastOnboardingTimestamp": 1724636886, + "mac": "98254A000000", + "model": "S200B", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "parent_device_id": "0000000000000000000000000000000000000000", + "region": "Australia/Canberra", + "report_interval": 16, + "rssi": -57, + "signal_level": 3, + "specs": "US", + "status": "online", + "status_follow_edge": false, + "type": "SMART.TAPOSENSOR" + } + ], + "start_index": 0, + "sum": 5 + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2024-11-13 09:26:28", + "seconds_from_1970": 1731450388 + } + } + }, + "getConnectionType": { + "link_type": "ethernet" + }, + "getDeviceInfo": { + "device_info": { + "basic_info": { + "avatar": "hub_h200", + "bind_status": true, + "child_num": 0, + "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": "24-2F-D0-00-00-00", + "need_sync_sha1_password": 0, + "oem_id": "00000000000000000000000000000000", + "product_name": "Tapo Smart Hub", + "region": "US", + "status": "configured", + "sw_version": "1.3.6 Build 20240829 rel.71119" + }, + "info": { + "avatar": "hub_h200", + "bind_status": true, + "child_num": 0, + "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": "24-2F-D0-00-00-00", + "need_sync_sha1_password": 0, + "oem_id": "00000000000000000000000000000000", + "product_name": "Tapo Smart Hub", + "region": "US", + "status": "configured", + "sw_version": "1.3.6 Build 20240829 rel.71119" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + "enabled": "on", + "random_range": 120, + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": true, + "state": "normal" + } + } + }, + "getLedStatus": { + "led": { + "config": { + ".name": "config", + ".type": "led", + "enabled": "on" + } + } + }, + "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": 300, + "siren_type": "Doorbell Ring 3", + "volume": "6" + }, + "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+10:00", + "zone_id": "Australia/Canberra" + } + } + } +} 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 new file mode 100644 index 000000000..cec6b7595 --- /dev/null +++ b/tests/fixtures/smartcam/TC65_1.0_1.3.9.json @@ -0,0 +1,769 @@ +{ + "discovery_result": { + "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": { + "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": 4 + }, + { + "name": "led", + "version": 1 + }, + { + "name": "playback", + "version": 4 + }, + { + "name": "detection", + "version": 3 + }, + { + "name": "alert", + "version": 1 + }, + { + "name": "firmware", + "version": 2 + }, + { + "name": "account", + "version": 1 + }, + { + "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": "intrusionDetection", + "version": 2 + }, + { + "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": "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" + } + } + }, + "getCircularRecordingConfig": { + "harddisk_manage": { + "harddisk": { + ".name": "harddisk", + ".type": "storage", + "loop": "on" + } + } + }, + "getClockStatus": { + "system": { + "clock_status": { + "local_time": "2024-11-01 16:10:28", + "seconds_from_1970": 1730473828 + } + } + }, + "getConnectionType": { + "link_type": "wifi", + "rssi": "3", + "rssiValue": -58, + "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": "room", + "barcode": "", + "dev_id": "0000000000000000000000000000000000000000", + "device_alias": "#MASKED_NAME#", + "device_info": "TC65 1.0 IPC", + "device_model": "TC65", + "device_name": "#MASKED_NAME#", + "device_type": "SMART.IPCAMERA", + "features": "3", + "ffs": false, + "has_set_location_info": 1, + "hw_desc": "00000000000000000000000000000000", + "hw_version": "1.0", + "is_cal": true, + "latitude": 0, + "longitude": 0, + "mac": "A8-6E-84-00-00-00", + "oem_id": "00000000000000000000000000000000", + "sw_version": "1.3.9 Build 231024 Rel.72919n(4555)" + } + } + }, + "getFirmwareAutoUpgradeConfig": { + "auto_upgrade": { + "common": { + ".name": "common", + ".type": "on_off", + "enabled": "on", + "random_range": "120", + "time": "03:00" + } + } + }, + "getFirmwareUpdateStatus": { + "cloud_config": { + "upgrade_status": { + "lastUpgradingSuccess": false, + "state": "normal" + } + } + }, + "getLastAlarmInfo": { + "system": { + "last_alarm_info": { + "last_alarm_time": "1698149810", + "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": "4", + "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": "5", + "inf_end_time": "21600", + "inf_sensitivity": "4", + "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", + "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+01:00", + "timing_mode": "ntp", + "zone_id": "Europe/Amsterdam" + } + } + }, + "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", + "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/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/__init__.py b/tests/iot/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/iot/modules/__init__.py b/tests/iot/modules/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/iot/modules/test_ambientlight.py b/tests/iot/modules/test_ambientlight.py new file mode 100644 index 000000000..ff2bd92c2 --- /dev/null +++ b/tests/iot/modules/test_ambientlight.py @@ -0,0 +1,47 @@ +from pytest_mock import MockerFixture + +from kasa import Module +from kasa.iot import IotDimmer +from kasa.iot.modules.ambientlight import AmbientLight + +from ...device_fixtures import dimmer_iot + + +@dimmer_iot +def test_ambientlight_getters(dev: IotDimmer): + assert Module.IotAmbientLight in dev.modules + ambientlight: AmbientLight = dev.modules[Module.IotAmbientLight] + + assert ambientlight.enabled == ambientlight.config["enable"] + assert ambientlight.presets == ambientlight.config["level_array"] + + assert ( + ambientlight.ambientlight_brightness + == ambientlight.data["get_current_brt"]["value"] + ) + + +@dimmer_iot +async def test_ambientlight_setters(dev: IotDimmer, mocker: MockerFixture): + ambientlight: AmbientLight = dev.modules[Module.IotAmbientLight] + query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper") + + await ambientlight.set_enabled(True) + query_helper.assert_called_with("smartlife.iot.LAS", "set_enable", {"enable": True}) + + await ambientlight.set_brightness_limit(10) + query_helper.assert_called_with( + "smartlife.iot.LAS", "set_brt_level", {"index": 0, "value": 10} + ) + + +@dimmer_iot +def test_ambientlight_feature(dev: IotDimmer): + assert Module.IotAmbientLight in dev.modules + ambientlight: AmbientLight = dev.modules[Module.IotAmbientLight] + + enabled = dev.features["ambient_light_enabled"] + assert ambientlight.enabled == enabled.value + + brightness = dev.features["ambient_light"] + assert ambientlight.ambientlight_brightness == brightness.value diff --git a/tests/iot/modules/test_cloud.py b/tests/iot/modules/test_cloud.py new file mode 100644 index 000000000..ec7f8f834 --- /dev/null +++ b/tests/iot/modules/test_cloud.py @@ -0,0 +1,13 @@ +from kasa import Device, Module + +from ...device_fixtures import device_iot + + +@device_iot +def test_cloud(dev: Device): + cloud = dev.modules.get(Module.IotCloud) + assert cloud + info = cloud.info + assert info + assert isinstance(info.provisioned, int) + assert cloud.is_connected == bool(info.cloud_connected) diff --git a/tests/iot/modules/test_dimmer.py b/tests/iot/modules/test_dimmer.py new file mode 100644 index 000000000..e4b267610 --- /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): + 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): + 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): + 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): + 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): + 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): + 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/iot/modules/test_emeter.py b/tests/iot/modules/test_emeter.py new file mode 100644 index 000000000..54fd02b2e --- /dev/null +++ b/tests/iot/modules/test_emeter.py @@ -0,0 +1,201 @@ +import datetime +from unittest.mock import Mock + +import pytest +from voluptuous import ( + All, + Any, + Coerce, + Range, + Schema, +) + +from kasa import Device, DeviceType, EmeterStatus, Module +from kasa.interfaces.energy import Energy +from kasa.iot import IotStrip +from kasa.iot.modules.emeter import Emeter +from tests.conftest import has_emeter_iot, no_emeter_iot + +CURRENT_CONSUMPTION_SCHEMA = Schema( + Any( + { + "voltage_mv": Any(All(float, Range(min=0, max=300000)), int, None), + "power_mw": Any(Coerce(float), None), + "current_ma": Any(All(float), int, None), + "energy_wh": Any(Coerce(float), None), + "total_wh": Any(Coerce(float), None), + "voltage": Any(All(float, Range(min=0, max=300)), None), + "power": Any(Coerce(float), None), + "current": Any(All(float), None), + "total": Any(Coerce(float), None), + "energy": Any(Coerce(float), None), + "slot_id": Any(Coerce(int), None), + }, + None, + ) +) + + +@no_emeter_iot +async def test_no_emeter(dev): + assert not dev.has_emeter + + with pytest.raises(AttributeError): + await dev.get_emeter_realtime() + + 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_iot +async def test_get_emeter_realtime(dev): + emeter = dev.modules[Module.Energy] + + current_emeter = await emeter.get_status() + # Check realtime query gets the same value as status property + # iot _query_helper strips out the error code from module responses. + # but it's not stripped out of the _modular_update queries. + assert current_emeter == {k: v for k, v in emeter.status.items() if k != "err_code"} + CURRENT_CONSUMPTION_SCHEMA(current_emeter) + + +@has_emeter_iot +@pytest.mark.requires_dummy +async def test_get_emeter_daily(dev): + emeter = dev.modules[Module.Energy] + + assert await emeter.get_daily_stats(year=1900, month=1) == {} + + d = await emeter.get_daily_stats() + assert len(d) > 0 + + k, v = d.popitem() + assert isinstance(k, int) + assert isinstance(v, float) + + # Test kwh (energy, energy_wh) + d = await emeter.get_daily_stats(kwh=False) + k2, v2 = d.popitem() + assert v * 1000 == v2 + + +@has_emeter_iot +@pytest.mark.requires_dummy +async def test_get_emeter_monthly(dev): + emeter = dev.modules[Module.Energy] + + assert await emeter.get_monthly_stats(year=1900) == {} + + d = await emeter.get_monthly_stats() + assert len(d) > 0 + + k, v = d.popitem() + assert isinstance(k, int) + assert isinstance(v, float) + + # Test kwh (energy, energy_wh) + d = await emeter.get_monthly_stats(kwh=False) + k2, v2 = d.popitem() + assert v * 1000 == v2 + + +@has_emeter_iot +async def test_emeter_status(dev): + emeter = dev.modules[Module.Energy] + + d = await emeter.get_status() + + with pytest.raises(KeyError): + assert d["foo"] + + assert d["power_mw"] == d["power"] * 1000 + # bulbs have only power according to tplink simulator. + if ( + dev.device_type is not DeviceType.Bulb + and dev.device_type is not DeviceType.LightStrip + ): + assert d["voltage_mv"] == d["voltage"] * 1000 + + assert d["current_ma"] == d["current"] * 1000 + assert d["total_wh"] == d["total"] * 1000 + + +@pytest.mark.skip("not clearing your stats..") +@has_emeter_iot +async def test_erase_emeter_stats(dev): + emeter = dev.modules[Module.Energy] + + await emeter.erase_emeter() + + +@has_emeter_iot +async def test_current_consumption(dev): + emeter = dev.modules[Module.Energy] + x = emeter.current_consumption + assert isinstance(x, float) + assert x >= 0.0 + + +async def test_emeterstatus_missing_current(): + """KL125 does not report 'current' for emeter.""" + regular = EmeterStatus( + {"err_code": 0, "power_mw": 0, "total_wh": 13, "current_ma": 123} + ) + assert regular["current"] == 0.123 + + with pytest.raises(KeyError): + regular["invalid_key"] + + missing_current = EmeterStatus({"err_code": 0, "power_mw": 0, "total_wh": 13}) + assert missing_current["current"] is None + + +async def test_emeter_daily(): + """Test fetching the emeter for today. + + This test uses inline data since the fixtures + will not have data for the current day. + """ + emeter_data = { + "get_daystat": { + "day_list": [{"day": 1, "energy_wh": 8, "month": 1, "year": 2023}], + "err_code": 0, + } + } + + class MockEmeter(Emeter): + @property + def data(self): + return emeter_data + + emeter = MockEmeter(Mock(), "emeter") + now = datetime.datetime.now() + emeter_data["get_daystat"]["day_list"].append( + {"day": now.day, "energy_wh": 500, "month": now.month, "year": now.year} + ) + assert emeter.consumption_today == 0.500 + + +@has_emeter_iot +async def test_supported(dev: Device): + energy_module = dev.modules.get(Module.Energy) + assert energy_module + + 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..29436785f --- /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): + # 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): + 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): + 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 new file mode 100644 index 000000000..2d1ccbcc7 --- /dev/null +++ b/tests/iot/modules/test_motion.py @@ -0,0 +1,114 @@ +import pytest +from pytest_mock import MockerFixture + +from kasa import KasaException, Module +from kasa.iot import IotDimmer +from kasa.iot.modules.motion import Motion, Range + +from ...device_fixtures import dimmer_iot + + +@dimmer_iot +def test_motion_getters(dev: IotDimmer): + assert Module.IotMotion in dev.modules + motion: Motion = dev.modules[Module.IotMotion] + + assert motion.enabled == motion.config["enable"] + assert motion.inactivity_timeout == motion.config["cold_time"] + assert motion.range.value == motion.config["trigger_index"] + + +@dimmer_iot +async def test_motion_setters(dev: IotDimmer, mocker: MockerFixture): + motion: Motion = dev.modules[Module.IotMotion] + query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper") + + await motion.set_enabled(True) + query_helper.assert_called_with("smartlife.iot.PIR", "set_enable", {"enable": True}) + + await motion.set_inactivity_timeout(10) + query_helper.assert_called_with( + "smartlife.iot.PIR", "set_cold_time", {"cold_time": 10} + ) + + +@dimmer_iot +async def test_motion_range(dev: IotDimmer, mocker: MockerFixture): + motion: Motion = dev.modules[Module.IotMotion] + query_helper = mocker.patch("kasa.iot.IotDimmer._query_helper") + + for range in Range: + await motion.set_range(range) + query_helper.assert_called_with( + "smartlife.iot.PIR", + "set_trigger_sens", + {"index": range.value}, + ) + + +@dimmer_iot +async def test_motion_range_from_string(dev: IotDimmer, mocker: MockerFixture): + 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): + 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): + 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): + assert Module.IotMotion in dev.modules + motion: Motion = dev.modules[Module.IotMotion] + + pir_enabled = dev.features["pir_enabled"] + assert motion.enabled == pir_enabled.value diff --git a/tests/iot/modules/test_schedule.py b/tests/iot/modules/test_schedule.py new file mode 100644 index 000000000..4a4ffdee6 --- /dev/null +++ b/tests/iot/modules/test_schedule.py @@ -0,0 +1,18 @@ +import pytest + +from kasa import Device, Module +from kasa.iot.modules.rulemodule import Action, TimeOption + +from ...device_fixtures import device_iot + + +@device_iot +@pytest.mark.xdist_group(name="caplog") +def test_schedule(dev: Device, caplog: pytest.LogCaptureFixture): + schedule = dev.modules.get(Module.IotSchedule) + assert schedule + if rules := schedule.rules: + first = rules[0] + assert isinstance(first.sact, Action) + assert isinstance(first.stime_opt, TimeOption) + assert "Unable to read rule list" not in caplog.text diff --git a/tests/iot/modules/test_usage.py b/tests/iot/modules/test_usage.py new file mode 100644 index 000000000..7b2c0eed6 --- /dev/null +++ b/tests/iot/modules/test_usage.py @@ -0,0 +1,86 @@ +import datetime +from unittest.mock import Mock + +from kasa.iot.modules import Usage + + +def test_usage_convert_stat_data(): + usage = Usage(None, module="usage") + + test_data = [] + assert usage._convert_stat_data(test_data, "day") == {} + + test_data = [ + {"year": 2016, "month": 5, "day": 2, "time": 20}, + {"year": 2016, "month": 5, "day": 4, "time": 30}, + ] + d = usage._convert_stat_data(test_data, "day") + assert len(d) == len(test_data) + assert isinstance(d, dict) + k, v = d.popitem() + assert isinstance(k, int) + assert isinstance(v, int) + assert k == 4 + assert v == 30 + + +def test_usage_today(): + """Test fetching the usage for today. + + This test uses inline data since the fixtures + will not have data for the current day. + """ + emeter_data = { + "get_daystat": { + "day_list": [], + "err_code": 0, + } + } + + class MockUsage(Usage): + @property + def data(self): + return emeter_data + + usage = MockUsage(Mock(), "usage") + assert usage.usage_today is None + now = datetime.datetime.now() + emeter_data["get_daystat"]["day_list"].extend( + [ + {"day": now.day - 1, "time": 200, "month": now.month - 1, "year": now.year}, + {"day": now.day, "time": 500, "month": now.month, "year": now.year}, + {"day": now.day + 1, "time": 100, "month": now.month + 1, "year": now.year}, + ] + ) + assert usage.usage_today == 500 + + +def test_usage_this_month(): + """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 = { + "get_monthstat": { + "month_list": [], + "err_code": 0, + } + } + + class MockUsage(Usage): + @property + def data(self): + return emeter_data + + usage = MockUsage(Mock(), "usage") + assert usage.usage_this_month is None + now = datetime.datetime.now() + emeter_data["get_monthstat"]["month_list"].extend( + [ + {"time": 200, "month": now.month - 1, "year": now.year}, + {"time": 500, "month": now.month, "year": now.year}, + {"time": 100, "month": now.month + 1, "year": now.year}, + ] + ) + assert usage.usage_this_month == 500 diff --git a/tests/iot/test_iotbulb.py b/tests/iot/test_iotbulb.py new file mode 100644 index 000000000..4d40dff67 --- /dev/null +++ b/tests/iot/test_iotbulb.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +import re + +import pytest +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): + 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): + 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_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) + + +@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 + 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): + 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_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): + """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): + behavior = await dev.get_turn_on_behavior() + assert behavior diff --git a/tests/iot/test_iotdevice.py b/tests/iot/test_iotdevice.py new file mode 100644 index 000000000..16dac35ff --- /dev/null +++ b/tests/iot/test_iotdevice.py @@ -0,0 +1,311 @@ +"""Module for common iotdevice tests.""" + +import re +from datetime import datetime + +import pytest +from voluptuous import ( + REMOVE_EXTRA, + All, + Any, + Boolean, + In, + Invalid, + Optional, + Range, + Schema, +) + +from kasa import DeviceType, KasaException, Module +from kasa.iot import IotDevice +from kasa.iot.iotmodule import _merge_dict +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} +) + + +def check_mac(x): + if re.match("[0-9a-f]{2}([-:])[0-9a-f]{2}(\\1[0-9a-f]{2}){4}$", x.lower()): + return x + raise Invalid(x) + + +SYSINFO_SCHEMA = Schema( + { + "active_mode": In(["schedule", "none", "count_down"]), + "alias": str, + "dev_name": str, + "deviceId": str, + "feature": str, + "fwId": str, + "hwId": str, + "hw_ver": str, + "icon_hash": str, + "led_off": Boolean, + "latitude": Any(All(float, Range(min=-90, max=90)), 0, None), + "latitude_i": Any( + All(int, Range(min=-900000, max=900000)), + All(float, Range(min=-900000, max=900000)), + 0, + None, + ), + "longitude": Any(All(float, Range(min=-180, max=180)), 0, None), + "longitude_i": Any( + All(int, Range(min=-18000000, max=18000000)), + All(float, Range(min=-18000000, max=18000000)), + 0, + None, + ), + "mac": check_mac, + "model": str, + "oemId": str, + "on_time": int, + "relay_state": int, + "rssi": Any(int, None), # rssi can also be positive, see #54 + "sw_ver": str, + "type": str, + "mic_type": str, + "updating": Boolean, + # these are available on hs220 + "brightness": int, + "preferred_state": [ + {"brightness": All(int, Range(min=0, max=100)), "index": int} + ], + "next_action": {"type": int}, + "child_num": Optional(Any(None, int)), + "children": Optional(list), + }, + extra=REMOVE_EXTRA, +) + + +@device_iot +async def test_state_info(dev): + assert isinstance(dev.state_information, dict) + + +@pytest.mark.requires_dummy +@device_iot +async def test_invalid_connection(mocker, dev): + 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): + """Test that the initial update performs second query if emeter is available.""" + dev._last_update = {} + dev._legacy_features = set() + spy = mocker.spy(dev.protocol, "query") + await dev.update() + # Devices with small buffers may require 3 queries + expected_queries = 2 if dev.max_device_response_size > 4096 else 3 + assert spy.call_count == expected_queries + len(dev.children) + + +@no_emeter_iot +async def test_initial_update_no_emeter(dev, mocker): + """Test that the initial update performs second query if emeter is available.""" + dev._last_update = {} + dev._legacy_features = set() + spy = mocker.spy(dev.protocol, "query") + await dev.update() + # child calls will happen if a child has a module with a query (e.g. schedule) + child_calls = 0 + for child in dev.children: + for module in child.modules.values(): + if module.query(): + child_calls += 1 + break + # 2 parent are necessary as some devices crash on unexpected modules + # See #105, #120, #161 + assert spy.call_count == 2 + child_calls + + +@device_iot +async def test_query_helper(dev): + with pytest.raises(KasaException): + await dev._query_helper("test", "testcmd", {}) + # TODO check for unwrapping? + + +@device_iot +@turn_on +async def test_state(dev, turn_on): + 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() + assert not dev.is_on + assert dev.is_off + + await dev.turn_on() + await dev.update() + assert dev.is_on + assert not dev.is_off + else: + await dev.turn_on() + await dev.update() + assert dev.is_on + assert not dev.is_off + + await dev.turn_off() + await dev.update() + assert not dev.is_on + assert dev.is_off + + +@device_iot +@turn_on +async def test_on_since(dev, turn_on): + 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: + assert dev.on_since is None + elif orig_state: + assert isinstance(dev.on_since, datetime) + else: + assert dev.on_since is None + + +@device_iot +async def test_time(dev): + assert isinstance(dev.modules[Module.Time].time, datetime) + + +@device_iot +async def test_timezone(dev): + TZ_SCHEMA(await dev.modules[Module.Time].get_timezone()) + + +@device_iot +async def test_hw_info(dev): + SYSINFO_SCHEMA(dev.hw_info) + + +@device_iot +async def test_location(dev): + SYSINFO_SCHEMA(dev.location) + + +@device_iot +async def test_rssi(dev): + SYSINFO_SCHEMA({"rssi": dev.rssi}) # wrapping for vol + + +@device_iot +async def test_mac(dev): + SYSINFO_SCHEMA({"mac": dev.mac}) # wrapping for val + + +@device_iot +async def test_representation(dev): + pattern = re.compile(r"") + assert pattern.match(str(dev)) + + +@device_iot +async def test_children(dev): + """Make sure that children property is exposed by every device.""" + if dev.device_type is DeviceType.Strip: + assert len(dev.children) > 0 + else: + assert len(dev.children) == 0 + + +@device_iot +async def test_modules_preserved(dev: IotDevice): + """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() + assert dev._last_update["some_module_not_being_updated"] == "should_be_kept" + + +@device_iot +async def test_internal_state(dev): + """Make sure the internal state returns the last update results.""" + assert dev.internal_state == dev._last_update + + +@device_iot +async def test_features(dev): + """Make sure features is always accessible.""" + sysinfo = dev._last_update["system"]["get_sysinfo"] + if "feature" in sysinfo: + assert dev._legacy_features == set(sysinfo["feature"].split(":")) + else: + assert dev._legacy_features == set() + + +@device_iot +async def test_max_device_response_size(dev): + """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): + """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): + """Test that unsupported modules do not break the device.""" + for module in dev.modules.values(): + assert module.is_supported is not None + await dev.update() + for module in dev.modules.values(): + assert module.is_supported is not None + + +async def test_get_modules(): + """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" + ) + from kasa.iot.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.IotCloud) + assert module + assert module.device == dummy_device + assert isinstance(module, Cloud) + + # Invalid modules + module = dummy_device.modules.get("DummyModule") + assert module is None + + module = dummy_device.modules.get(Module.Cloud) + assert module is None + + +def test_merge_dict(): + """Test the recursive dict merge.""" + dest = {"a": 1, "b": {"c": 2, "d": 3}} + source = {"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}} + source = { + "smartlife.iot.common.emeter": {"get_daystat": {"month": 8, "year": 2024}} + } + assert _merge_dict(dest, source) == { + "smartlife.iot.common.emeter": { + "get_realtime": None, + "get_daystat": {"month": 8, "year": 2024}, + } + } diff --git a/tests/iot/test_iotdimmer.py b/tests/iot/test_iotdimmer.py new file mode 100644 index 000000000..38f440e70 --- /dev/null +++ b/tests/iot/test_iotdimmer.py @@ -0,0 +1,181 @@ +import pytest + +from kasa import DeviceType, Module +from kasa.iot import IotDimmer +from tests.conftest import dimmer_iot, handle_turn_on, turn_on + + +@dimmer_iot +async def test_set_brightness(dev): + light = dev.modules.get(Module.Light) + assert light + await handle_turn_on(dev, False) + await dev.update() + assert dev.is_on is False + + await light.set_brightness(99) + await dev.update() + assert light.brightness == 99 + assert dev.is_on is True + + await light.set_brightness(0) + await dev.update() + assert light.brightness == 99 + assert dev.is_on is False + + +@dimmer_iot +@turn_on +async def test_set_brightness_transition(dev, turn_on, mocker): + light = dev.modules.get(Module.Light) + assert light + await handle_turn_on(dev, turn_on) + query_helper = mocker.spy(IotDimmer, "_query_helper") + + await light.set_brightness(99, transition=1000) + query_helper.assert_called_with( + mocker.ANY, + "smartlife.iot.dimmer", + "set_dimmer_transition", + {"brightness": 99, "duration": 1000}, + ) + await dev.update() + assert light.brightness == 99 + assert dev.is_on + + await light.set_brightness(0, transition=1000) + await dev.update() + assert dev.is_on is False + + +@dimmer_iot +async def test_set_brightness_invalid(dev): + light = dev.modules.get(Module.Light) + assert light + for invalid_brightness in [-1, 101]: + with pytest.raises(ValueError, match="Invalid brightness"): + await light.set_brightness(invalid_brightness) + + for invalid_type in [0.5, "foo"]: + with pytest.raises(TypeError, match="Brightness must be an integer"): + await light.set_brightness(invalid_type) + + +@dimmer_iot +async def test_set_brightness_invalid_transition(dev): + light = dev.modules.get(Module.Light) + assert light + for invalid_transition in [-1]: + with pytest.raises(ValueError, match="Transition value .+? is not valid."): + await light.set_brightness(1, transition=invalid_transition) + for invalid_type in [0.5, "foo"]: + with pytest.raises(TypeError, match="Transition must be integer"): + await light.set_brightness(1, transition=invalid_type) + + +@dimmer_iot +async def test_turn_on_transition(dev, mocker): + light = dev.modules.get(Module.Light) + assert light + query_helper = mocker.spy(IotDimmer, "_query_helper") + original_brightness = light.brightness + + await dev.turn_on(transition=1000) + query_helper.assert_called_with( + mocker.ANY, + "smartlife.iot.dimmer", + "set_dimmer_transition", + {"brightness": original_brightness, "duration": 1000}, + ) + await dev.update() + assert dev.is_on + assert light.brightness == original_brightness + + +@dimmer_iot +async def test_turn_off_transition(dev, mocker): + light = dev.modules.get(Module.Light) + assert light + await handle_turn_on(dev, True) + query_helper = mocker.spy(IotDimmer, "_query_helper") + original_brightness = light.brightness + + await dev.turn_off(transition=1000) + await dev.update() + + assert dev.is_off + assert light.brightness == original_brightness + query_helper.assert_called_with( + mocker.ANY, + "smartlife.iot.dimmer", + "set_dimmer_transition", + {"brightness": 0, "duration": 1000}, + ) + + +@dimmer_iot +@turn_on +async def test_set_dimmer_transition(dev, turn_on, mocker): + light = dev.modules.get(Module.Light) + assert light + await handle_turn_on(dev, turn_on) + query_helper = mocker.spy(IotDimmer, "_query_helper") + + await dev.set_dimmer_transition(99, 1000) + query_helper.assert_called_with( + mocker.ANY, + "smartlife.iot.dimmer", + "set_dimmer_transition", + {"brightness": 99, "duration": 1000}, + ) + await dev.update() + assert dev.is_on + assert light.brightness == 99 + + +@dimmer_iot +@turn_on +async def test_set_dimmer_transition_to_off(dev, turn_on, mocker): + light = dev.modules.get(Module.Light) + assert light + await handle_turn_on(dev, turn_on) + original_brightness = light.brightness + query_helper = mocker.spy(IotDimmer, "_query_helper") + + await dev.set_dimmer_transition(0, 1000) + await dev.update() + + assert dev.is_off + assert light.brightness == original_brightness + query_helper.assert_called_with( + mocker.ANY, + "smartlife.iot.dimmer", + "set_dimmer_transition", + {"brightness": 0, "duration": 1000}, + ) + + +@dimmer_iot +async def test_set_dimmer_transition_invalid_brightness(dev): + 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) + + +@dimmer_iot +async def test_set_dimmer_transition_invalid_transition(dev): + 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) + + +@dimmer_iot +def test_device_type_dimmer(dev): + assert dev.device_type == DeviceType.Dimmer diff --git a/tests/iot/test_iotlightstrip.py b/tests/iot/test_iotlightstrip.py new file mode 100644 index 000000000..23eb61dc9 --- /dev/null +++ b/tests/iot/test_iotlightstrip.py @@ -0,0 +1,83 @@ +import pytest + +from kasa import DeviceType, Module +from kasa.iot import IotLightStrip +from kasa.iot.modules import LightEffect +from tests.conftest import lightstrip_iot + + +@lightstrip_iot +async def test_lightstrip_length(dev: IotLightStrip): + assert dev.device_type == DeviceType.LightStrip + assert dev.length == dev.sys_info["length"] + + +@lightstrip_iot +async def test_lightstrip_effect(dev: IotLightStrip): + le: LightEffect = dev.modules[Module.LightEffect] + assert isinstance(le._deprecated_effect, dict) + for k in ["brightness", "custom", "enable", "id", "name"]: + assert k in le._deprecated_effect + + +@lightstrip_iot +async def test_effects_lightstrip_set_effect(dev: IotLightStrip): + le: LightEffect = dev.modules[Module.LightEffect] + with pytest.raises( + ValueError, match="The effect Not real is not a built in effect" + ): + await le.set_effect("Not real") + + await le.set_effect("Candy Cane") + await dev.update() + assert le.effect == "Candy Cane" + + +@lightstrip_iot +@pytest.mark.parametrize("brightness", [100, 50]) +async def test_effects_lightstrip_set_effect_brightness( + dev: IotLightStrip, brightness, mocker +): + query_helper = mocker.patch("kasa.iot.IotLightStrip._query_helper") + le: LightEffect = dev.modules[Module.LightEffect] + + # test that default brightness works (100 for candy cane) + if brightness == 100: + await le.set_effect("Candy Cane") + else: + await le.set_effect("Candy Cane", brightness=brightness) + + args, kwargs = query_helper.call_args_list[0] + payload = args[2] + assert payload["brightness"] == brightness + + +@lightstrip_iot +@pytest.mark.parametrize("transition", [500, 1000]) +async def test_effects_lightstrip_set_effect_transition( + dev: IotLightStrip, transition, mocker +): + query_helper = mocker.patch("kasa.iot.IotLightStrip._query_helper") + le: LightEffect = dev.modules[Module.LightEffect] + + # test that default (500 for candy cane) transition works + if transition == 500: + await le.set_effect("Candy Cane") + else: + await le.set_effect("Candy Cane", transition=transition) + + args, kwargs = query_helper.call_args_list[0] + payload = args[2] + assert payload["transition"] == transition + + +@lightstrip_iot +async def test_effects_lightstrip_has_effects(dev: IotLightStrip): + le: LightEffect = dev.modules[Module.LightEffect] + assert le is not None + assert le.effect_list + + +@lightstrip_iot +def test_device_type_lightstrip(dev): + 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..50c1df618 --- /dev/null +++ b/tests/iot/test_iotstrip.py @@ -0,0 +1,44 @@ +from unittest.mock import AsyncMock + +from kasa import Module +from tests.conftest import strip_emeter_iot, strip_iot + + +@strip_iot +async def test_strip_update_and_child_update_behaviors(dev): + 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): + await dev.update() + child = dev.children[0] + + 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, mocker): + 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..640603b16 --- /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(): + """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(): + """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(): + """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, +): + """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(): + """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, +): + """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(): + """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(): + """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): + """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(): + """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): + """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(): + """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(): + """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 new file mode 100644 index 000000000..b6fd2a673 --- /dev/null +++ b/tests/iot/test_wallswitch.py @@ -0,0 +1,9 @@ +from ..device_fixtures import wallswitch_iot + + +@wallswitch_iot +def test_wallswitch_motion(dev): + """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 + assert "ambient" 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/protocols/test_iotprotocol.py b/tests/protocols/test_iotprotocol.py new file mode 100644 index 000000000..0db91fcab --- /dev/null +++ b/tests/protocols/test_iotprotocol.py @@ -0,0 +1,960 @@ +import asyncio +import errno +import importlib +import inspect +import json +import logging +import os +import pkgutil +import struct +import sys +from typing import cast +from unittest.mock import AsyncMock + +import pytest + +from kasa.credentials import Credentials +from kasa.device import Device +from kasa.deviceconfig import DeviceConfig +from kasa.exceptions import KasaException, TimeoutError +from kasa.iot import IotDevice +from kasa.protocols.iotprotocol import IotProtocol, _deprecated_TPLinkSmartHomeProtocol +from kasa.protocols.protocol import ( + BaseProtocol, + mask_mac, + redact_data, +) +from kasa.transports.aestransport import AesTransport +from kasa.transports.basetransport import BaseTransport +from kasa.transports.klaptransport import KlapTransport, KlapTransportV2 +from kasa.transports.xortransport import XorEncryption, XorTransport + +from ..conftest import device_iot +from ..fakeprotocol_iot import FakeIotTransport + + +@pytest.mark.parametrize( + ("protocol_class", "transport_class"), + [ + (_deprecated_TPLinkSmartHomeProtocol, XorTransport), + (IotProtocol, XorTransport), + ], + 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(_, __): + reader = mocker.patch("asyncio.StreamReader") + writer = mocker.patch("asyncio.StreamWriter") + + mocker.patch( + "asyncio.StreamWriter.write", side_effect=Exception("dummy exception") + ) + + return reader, writer + + conn = mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) + config = DeviceConfig("127.0.0.1") + with pytest.raises(KasaException): + await protocol_class(transport=transport_class(config=config)).query( + {}, retry_count=retry_count + ) + + assert conn.call_count == retry_count + 1 + + +@pytest.mark.parametrize( + ("protocol_class", "transport_class"), + [ + (_deprecated_TPLinkSmartHomeProtocol, XorTransport), + (IotProtocol, XorTransport), + ], + ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), +) +async def test_protocol_no_retry_on_unreachable( + mocker, protocol_class, transport_class +): + conn = mocker.patch( + "asyncio.open_connection", + side_effect=OSError(errno.EHOSTUNREACH, "No route to host"), + ) + config = DeviceConfig("127.0.0.1") + with pytest.raises(KasaException): + await protocol_class(transport=transport_class(config=config)).query( + {}, retry_count=5 + ) + + assert conn.call_count == 1 + + +@pytest.mark.parametrize( + ("protocol_class", "transport_class"), + [ + (_deprecated_TPLinkSmartHomeProtocol, XorTransport), + (IotProtocol, XorTransport), + ], + ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), +) +async def test_protocol_no_retry_connection_refused( + mocker, protocol_class, transport_class +): + conn = mocker.patch( + "asyncio.open_connection", + side_effect=ConnectionRefusedError, + ) + config = DeviceConfig("127.0.0.1") + with pytest.raises(KasaException): + await protocol_class(transport=transport_class(config=config)).query( + {}, retry_count=5 + ) + + assert conn.call_count == 1 + + +@pytest.mark.parametrize( + ("protocol_class", "transport_class"), + [ + (_deprecated_TPLinkSmartHomeProtocol, XorTransport), + (IotProtocol, XorTransport), + ], + ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), +) +async def test_protocol_retry_recoverable_error( + mocker, protocol_class, transport_class +): + conn = mocker.patch( + "asyncio.open_connection", + side_effect=OSError(errno.ECONNRESET, "Connection reset by peer"), + ) + config = DeviceConfig("127.0.0.1") + with pytest.raises(KasaException): + await protocol_class(transport=transport_class(config=config)).query( + {}, retry_count=5 + ) + + assert conn.call_count == 6 + + +@pytest.mark.parametrize( + ("protocol_class", "transport_class", "encryption_class"), + [ + ( + _deprecated_TPLinkSmartHomeProtocol, + XorTransport, + _deprecated_TPLinkSmartHomeProtocol, + ), + (IotProtocol, XorTransport, XorEncryption), + ], + ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), +) +@pytest.mark.parametrize("retry_count", [1, 3, 5]) +async def test_protocol_reconnect( + mocker, retry_count, protocol_class, transport_class, encryption_class +): + remaining = retry_count + encrypted = encryption_class.encrypt('{"great":"success"}')[ + transport_class.BLOCK_SIZE : + ] + + def _fail_one_less_than_retry_count(*_): + nonlocal remaining + remaining -= 1 + if remaining: + raise Exception("Simulated write failure") + + async def _mock_read(byte_count): + 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(_, __): + reader = mocker.patch("asyncio.StreamReader") + writer = mocker.patch("asyncio.StreamWriter") + mocker.patch.object(writer, "write", _fail_one_less_than_retry_count) + 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) + response = await protocol.query({}, retry_count=retry_count) + 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_cancellation_during_write( + mocker, protocol_class, transport_class, encryption_class +): + attempts = 0 + encrypted = encryption_class.encrypt('{"great":"success"}')[ + transport_class.BLOCK_SIZE : + ] + + def _cancel_first_attempt(*_): + nonlocal attempts + attempts += 1 + if attempts == 1: + raise asyncio.CancelledError("Simulated task cancel") + + async def _mock_read(byte_count): + 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(_, __): + reader = mocker.patch("asyncio.StreamReader") + writer = mocker.patch("asyncio.StreamWriter") + mocker.patch.object(writer, "write", _cancel_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)) + conn_mock = mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) + with pytest.raises(asyncio.CancelledError): + await protocol.query({}) + writer_obj = protocol if hasattr(protocol, "writer") else protocol._transport + assert writer_obj.writer is None + conn_mock.assert_awaited_once() + 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_cancellation_during_connection( + mocker, protocol_class, transport_class, encryption_class +): + attempts = 0 + encrypted = encryption_class.encrypt('{"great":"success"}')[ + transport_class.BLOCK_SIZE : + ] + + async def _mock_read(byte_count): + 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(_, __): + nonlocal attempts + attempts += 1 + if attempts == 1: + raise asyncio.CancelledError("Simulated task cancel") + 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)) + conn_mock = mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) + with pytest.raises(asyncio.CancelledError): + await protocol.query({}) + + writer_obj = protocol if hasattr(protocol, "writer") else protocol._transport + assert writer_obj.writer is None + conn_mock.assert_awaited_once() + 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_write( + mocker, protocol_class, transport_class, encryption_class +): + attempts = 0 + encrypted = encryption_class.encrypt('{"great":"success"}')[ + transport_class.BLOCK_SIZE : + ] + + def _timeout_first_attempt(*_): + nonlocal attempts + attempts += 1 + if attempts == 1: + raise TimeoutError("Simulated timeout") + + async def _mock_read(byte_count): + 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(_, __): + 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, protocol_class, transport_class, encryption_class +): + attempts = 0 + encrypted = encryption_class.encrypt('{"great":"success"}')[ + transport_class.BLOCK_SIZE : + ] + + async def _mock_read(byte_count): + 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(_, __): + 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, protocol_class, transport_class, encryption_class +): + encrypted = encryption_class.encrypt('{"great":"success"}')[ + transport_class.BLOCK_SIZE : + ] + + def _timeout_all_attempts(*_): + raise TimeoutError("Simulated timeout") + + async def _mock_read(byte_count): + 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(_, __): + 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, protocol_class, transport_class, encryption_class +): + encrypted = encryption_class.encrypt('{"great":"success"}')[ + transport_class.BLOCK_SIZE : + ] + + async def _mock_read(byte_count): + 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(_, __): + 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"), + [ + ( + _deprecated_TPLinkSmartHomeProtocol, + XorTransport, + _deprecated_TPLinkSmartHomeProtocol, + ), + (IotProtocol, XorTransport, XorEncryption), + ], + ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), +) +@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 +): + 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): + 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(_, __): + 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)) + mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) + response = await protocol.query({}) + assert response == {"great": "success"} + if log_level == logging.DEBUG: + assert "success" in caplog.text + else: + assert "success" not in caplog.text + + +@pytest.mark.parametrize( + ("protocol_class", "transport_class", "encryption_class"), + [ + ( + _deprecated_TPLinkSmartHomeProtocol, + XorTransport, + _deprecated_TPLinkSmartHomeProtocol, + ), + (IotProtocol, XorTransport, XorEncryption), + ], + ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), +) +@pytest.mark.parametrize("custom_port", [123, None]) +async def test_protocol_custom_port( + mocker, custom_port, protocol_class, transport_class, encryption_class +): + encrypted = encryption_class.encrypt('{"great":"success"}')[ + transport_class.BLOCK_SIZE : + ] + + async def _mock_read(byte_count): + 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(_, port): + reader = mocker.patch("asyncio.StreamReader") + writer = mocker.patch("asyncio.StreamWriter") + if custom_port is None: + assert port == 9999 + else: + assert port == custom_port + mocker.patch.object(reader, "readexactly", _mock_read) + mocker.patch.object(writer, "drain", new_callable=AsyncMock) + return reader, writer + + config = DeviceConfig("127.0.0.1", port_override=custom_port) + protocol = protocol_class(transport=transport_class(config=config)) + mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) + response = await protocol.query({}) + assert response == {"great": "success"} + + +@pytest.mark.parametrize( + "encrypt_class", + [_deprecated_TPLinkSmartHomeProtocol, XorEncryption], +) +@pytest.mark.parametrize( + "decrypt_class", + [_deprecated_TPLinkSmartHomeProtocol, XorEncryption], +) +def test_encrypt(encrypt_class, decrypt_class): + d = json.dumps({"foo": 1, "bar": 2}) + encrypted = encrypt_class.encrypt(d) + # encrypt adds a 4 byte header + encrypted = encrypted[4:] + assert d == decrypt_class.decrypt(encrypted) + + +@pytest.mark.parametrize( + "encrypt_class", + [_deprecated_TPLinkSmartHomeProtocol, XorEncryption], +) +def test_encrypt_unicode(encrypt_class): + d = "{'snowman': '\u2603'}" + + e = bytes( + [ + 208, + 247, + 132, + 234, + 133, + 242, + 159, + 254, + 144, + 183, + 141, + 173, + 138, + 104, + 240, + 115, + 84, + 41, + ] + ) + + encrypted = encrypt_class.encrypt(d) + # encrypt adds a 4 byte header + encrypted = encrypted[4:] + + assert e == encrypted + + +@pytest.mark.parametrize( + "decrypt_class", + [_deprecated_TPLinkSmartHomeProtocol, XorEncryption], +) +def test_decrypt_unicode(decrypt_class): + e = bytes( + [ + 208, + 247, + 132, + 234, + 133, + 242, + 159, + 254, + 144, + 183, + 141, + 173, + 138, + 104, + 240, + 115, + 84, + 41, + ] + ) + + d = "{'snowman': '\u2603'}" + + assert d == decrypt_class.decrypt(e) + + +def _get_subclasses(of_class): + package = sys.modules["kasa"] + subclasses = set() + for _, modname, _ in pkgutil.iter_modules(package.__path__): + importlib.import_module("." + modname, package="kasa") + module = sys.modules["kasa." + modname] + for name, obj in inspect.getmembers(module): + if ( + inspect.isclass(obj) + and issubclass(obj, of_class) + and name != "_deprecated_TPLinkSmartHomeProtocol" + ): + subclasses.add((name, obj)) + return sorted(subclasses) + + +@pytest.mark.parametrize( + "class_name_obj", _get_subclasses(BaseProtocol), ids=lambda t: t[0] +) +def test_protocol_init_signature(class_name_obj): + if class_name_obj[0].startswith("_"): + pytest.skip("Skipping internal protocols") + return + params = list(inspect.signature(class_name_obj[1].__init__).parameters.values()) + + assert len(params) == 2 + assert params[0].name == "self" + assert params[0].kind == inspect.Parameter.POSITIONAL_OR_KEYWORD + assert params[1].name == "transport" + assert params[1].kind == inspect.Parameter.KEYWORD_ONLY + + +@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()) + + assert len(params) == 2 + assert params[0].name == "self" + assert params[0].kind == inspect.Parameter.POSITIONAL_OR_KEYWORD + assert params[1].name == "config" + assert params[1].kind == inspect.Parameter.KEYWORD_ONLY + + +@pytest.mark.parametrize( + ("transport_class", "login_version", "expected_hash"), + [ + pytest.param( + AesTransport, + 1, + "eyJwYXNzd29yZCI6IlFtRnkiLCJ1c2VybmFtZSI6Ik1qQXhZVFppTXpBMU0yTmpNVFF5TW1ReVl6TTJOekJpTmpJMk1UWXlNakZrTWpJNU1Ea3lPUT09In0=", + id="aes-lv-1", + ), + pytest.param( + AesTransport, + 2, + "eyJwYXNzd29yZDIiOiJaVFE1Tm1aa01qQXhNelprTkdKaU56Z3lPR1ZpWWpCaFlqa3lOV0l4WW1RNU56Y3lNRGhsTkE9PSIsInVzZXJuYW1lIjoiTWpBeFlUWmlNekExTTJOak1UUXlNbVF5WXpNMk56QmlOakkyTVRZeU1qRmtNakk1TURreU9RPT0ifQ==", + id="aes-lv-2", + ), + pytest.param(KlapTransport, 1, "xBhMRGYWStVCVk9aSD8/6Q==", id="klap-lv-1"), + pytest.param(KlapTransport, 2, "xBhMRGYWStVCVk9aSD8/6Q==", id="klap-lv-2"), + pytest.param( + KlapTransportV2, + 1, + "tEmiensOcZkP9twDEZKwU3JJl3asmseKCP7N9sfatVo=", + id="klapv2-lv-1", + ), + pytest.param( + KlapTransportV2, + 2, + "tEmiensOcZkP9twDEZKwU3JJl3asmseKCP7N9sfatVo=", + id="klapv2-lv-2", + ), + pytest.param(XorTransport, None, None, id="xor"), + ], +) +@pytest.mark.parametrize( + ("credentials", "expected_blank"), + [ + pytest.param(Credentials("Foo", "Bar"), False, id="credentials"), + pytest.param(None, True, id="no-credentials"), + pytest.param(Credentials(None, "Bar"), True, id="no-username"), # type: ignore[arg-type] + ], +) +async def test_transport_credentials_hash( + mocker, transport_class, login_version, expected_hash, credentials, expected_blank +): + """Test that the actual hashing doesn't break and empty credential returns an empty hash.""" + host = "127.0.0.1" + + params = Device.ConnectionParameters( + device_family=Device.Family.SmartTapoPlug, + encryption_type=Device.EncryptionType.Xor, + login_version=login_version, + ) + config = DeviceConfig(host, credentials=credentials, connection_type=params) + transport = transport_class(config=config) + + credentials_hash = transport.credentials_hash + + expected = None if expected_blank else expected_hash + assert credentials_hash == expected + + +@pytest.mark.parametrize( + "transport_class", + [AesTransport, KlapTransport, KlapTransportV2, XorTransport], +) +async def test_transport_credentials_hash_from_config(mocker, transport_class): + """Test that credentials_hash provided via config sets correctly.""" + host = "127.0.0.1" + + credentials = Credentials("Foo", "Bar") + config = DeviceConfig(host, credentials=credentials) + transport = transport_class(config=config) + credentials_hash = transport.credentials_hash + config = DeviceConfig(host, credentials_hash=credentials_hash) + transport = transport_class(config=config) + + assert transport.credentials_hash == credentials_hash + + +@pytest.mark.parametrize( + ("error", "retry_expectation"), + [ + (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", + "OSErrorHostDown", + "OSErrorNetUnreach", + "OSErrorRetry", + "Exception", + ), +) +@pytest.mark.parametrize( + ("protocol_class", "transport_class"), + [ + (_deprecated_TPLinkSmartHomeProtocol, XorTransport), + (IotProtocol, XorTransport), + ], + ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), +) +async def test_protocol_will_retry_on_connect( + mocker, protocol_class, transport_class, error, retry_expectation +): + retry_count = 2 + conn = mocker.patch("asyncio.open_connection", side_effect=error) + config = DeviceConfig("127.0.0.1") + with pytest.raises(KasaException): + await protocol_class(transport=transport_class(config=config)).query( + {}, retry_count=retry_count + ) + + assert conn.call_count == (retry_count + 1 if retry_expectation else 1) + + +@pytest.mark.parametrize( + ("error", "retry_expectation"), + [ + (ConnectionRefusedError("dummy exception"), True), + (OSError(errno.EHOSTDOWN, os.strerror(errno.EHOSTDOWN)), True), + (OSError(errno.ECONNRESET, os.strerror(errno.ECONNRESET)), True), + (Exception("dummy exception"), True), + ], + ids=("ConnectionRefusedError", "OSErrorNoRetry", "OSErrorRetry", "Exception"), +) +@pytest.mark.parametrize( + ("protocol_class", "transport_class"), + [ + (_deprecated_TPLinkSmartHomeProtocol, XorTransport), + (IotProtocol, XorTransport), + ], + ids=("_deprecated_TPLinkSmartHomeProtocol", "IotProtocol-XorTransport"), +) +async def test_protocol_will_retry_on_write( + mocker, protocol_class, transport_class, error, retry_expectation +): + retry_count = 2 + writer = mocker.patch("asyncio.StreamWriter") + write_mock = mocker.patch.object(writer, "write", side_effect=error) + + def aio_mock_writer(_, __): + nonlocal writer + reader = mocker.patch("asyncio.StreamReader") + + return reader, writer + + conn = mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) + write_mock = mocker.patch("asyncio.StreamWriter.write", side_effect=error) + config = DeviceConfig("127.0.0.1") + with pytest.raises(KasaException): + await protocol_class(transport=transport_class(config=config)).query( + {}, retry_count=retry_count + ) + + expected_call_count = retry_count + 1 if retry_expectation else 1 + assert conn.call_count == expected_call_count + assert write_mock.call_count == expected_call_count + + +def test_deprecated_protocol(): + with pytest.deprecated_call(): + from kasa import TPLinkSmartHomeProtocol + + with pytest.raises(KasaException, match="host or transport must be supplied"): + proto = TPLinkSmartHomeProtocol() + host = "127.0.0.1" + proto = TPLinkSmartHomeProtocol(host=host) + assert proto.config.host == host + + +@device_iot +@pytest.mark.xdist_group(name="caplog") +async def test_iot_queries_redaction(dev: IotDevice, caplog: pytest.LogCaptureFixture): + """Test query sensitive info redaction.""" + if isinstance(dev.protocol._transport, FakeIotTransport): + device_id = "123456789ABCDEF" + cast(FakeIotTransport, dev.protocol._transport).proto["system"]["get_sysinfo"][ + "deviceId" + ] = device_id + else: # real device with --ip + device_id = dev.sys_info["deviceId"] + + # Info no message logging + caplog.set_level(logging.INFO) + await dev.update() + assert device_id not in caplog.text + + caplog.set_level(logging.DEBUG, logger="kasa") + # The fake iot protocol also logs so disable it + test_logger = logging.getLogger("kasa.tests.fakeprotocol_iot") + test_logger.setLevel(logging.INFO) + + # Debug no redaction + caplog.clear() + cast(IotProtocol, dev.protocol)._redact_data = False + await dev.update() + assert device_id in caplog.text + + # Debug redaction + caplog.clear() + cast(IotProtocol, dev.protocol)._redact_data = True + await dev.update() + assert device_id not in caplog.text + assert "REDACTED_" + device_id[9::] in caplog.text + + +async def test_redact_data(): + """Test redact data function.""" + data = { + "device_id": "123456789ABCDEF", + "owner": "0987654", + "mac": "12:34:56:78:90:AB", + "ip": "192.168.1", + "no_val": None, + } + excpected_data = { + "device_id": "REDACTED_ABCDEF", + "owner": "**REDACTED**", + "mac": "12:34:56:00:00:00", + "ip": "**REDACTEX**", + "no_val": None, + } + REDACTORS = { + "device_id": lambda x: "REDACTED_" + x[9::], + "owner": None, + "mac": mask_mac, + "ip": lambda x: "127.0.0." + x.split(".")[3], + } + + redacted_data = redact_data(data, REDACTORS) + + assert redacted_data == excpected_data diff --git a/tests/protocols/test_smartprotocol.py b/tests/protocols/test_smartprotocol.py new file mode 100644 index 000000000..514926353 --- /dev/null +++ b/tests/protocols/test_smartprotocol.py @@ -0,0 +1,571 @@ +import logging + +import pytest +import pytest_mock +from pytest_mock import MockerFixture + +from kasa.exceptions import ( + SMART_RETRYABLE_ERRORS, + DeviceError, + 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 ..fakeprotocol_smartcam import FakeSmartCamTransport + +DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}} +DUMMY_MULTIPLE_QUERY = { + "foobar": {"foo": "bar", "bar": "foo"}, + "barfoo": {"foo": "bar", "bar": "foo"}, +} +ERRORS = [e for e in SmartErrorCode if e != 0] + + +async def test_smart_queries(dummy_protocol, mocker: pytest_mock.MockerFixture): + mock_response = {"result": {"great": "success"}, "error_code": 0} + + mocker.patch.object(dummy_protocol._transport, "send", return_value=mock_response) + # test sending a method name as a string + resp = await dummy_protocol.query("foobar") + assert "foobar" in resp + assert resp["foobar"] == mock_response["result"] + + # test sending a method name as a dict + resp = await dummy_protocol.query(DUMMY_QUERY) + assert "foobar" in resp + assert resp["foobar"] == mock_response["result"] + + +@pytest.mark.parametrize("error_code", ERRORS, ids=lambda e: e.name) +async def test_smart_device_errors(dummy_protocol, mocker, error_code): + mock_response = {"result": {"great": "success"}, "error_code": error_code.value} + + send_mock = mocker.patch.object( + dummy_protocol._transport, "send", return_value=mock_response + ) + + with pytest.raises(KasaException): + await dummy_protocol.query(DUMMY_QUERY, retry_count=2) + + expected_calls = 3 if error_code in SMART_RETRYABLE_ERRORS else 1 + assert send_mock.call_count == expected_calls + + +@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 +): + """Test handling of unknown error codes.""" + mock_response = {"result": {"great": "success"}, "error_code": error_code} + + send_mock = mocker.patch.object( + dummy_protocol._transport, "send", return_value=mock_response + ) + + with pytest.raises(KasaException): # noqa: PT012 + res = await dummy_protocol.query(DUMMY_QUERY) + assert res is SmartErrorCode.INTERNAL_UNKNOWN_ERROR + + send_mock.assert_called_once() + assert f"received unknown error code: {error_code}" in caplog.text + + +@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 +): + mock_request = { + "foobar1": {"foo": "bar", "bar": "foo"}, + "foobar2": {"foo": "bar", "bar": "foo"}, + "foobar3": {"foo": "bar", "bar": "foo"}, + } + mock_response = { + "result": { + "responses": [ + {"method": "foobar1", "result": {"great": "success"}, "error_code": 0}, + { + "method": "foobar2", + "result": {"great": "success"}, + "error_code": error_code.value, + }, + {"method": "foobar3", "result": {"great": "success"}, "error_code": 0}, + ] + }, + "error_code": 0, + } + + send_mock = mocker.patch.object( + dummy_protocol._transport, "send", return_value=mock_response + ) + + resp_dict = await dummy_protocol.query(mock_request, retry_count=2) + assert resp_dict["foobar2"] == error_code + assert send_mock.call_count == 1 + assert len(resp_dict) == len(mock_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 +): + requests = {} + mock_response = { + "result": {"responses": []}, + "error_code": 0, + } + for i in range(request_size): + method = f"get_method_{i}" + requests[method] = {"foo": "bar", "bar": "foo"} + mock_response["result"]["responses"].append( + {"method": method, "result": {"great": "success"}, "error_code": 0} + ) + + send_mock = mocker.patch.object( + dummy_protocol._transport, "send", return_value=mock_response + ) + dummy_protocol._multi_request_batch_size = batch_size + + await dummy_protocol.query(requests, retry_count=0) + expected_count = int(request_size / batch_size) + (request_size % batch_size > 0) + assert send_mock.call_count == expected_count + + +async def test_smart_device_multiple_request_json_decode_failure( + dummy_protocol, mocker +): + """Test the logic to disable multiple requests on JSON_DECODE_FAIL_ERROR.""" + requests = {} + mock_responses = [] + + mock_json_error = { + "result": {"responses": []}, + "error_code": SmartErrorCode.JSON_DECODE_FAIL_ERROR.value, + } + for i in range(10): + method = f"get_method_{i}" + requests[method] = {"foo": "bar", "bar": "foo"} + mock_responses.append( + {"method": method, "result": {"great": "success"}, "error_code": 0} + ) + + send_mock = mocker.patch.object( + dummy_protocol._transport, + "send", + side_effect=[mock_json_error, *mock_responses], + ) + dummy_protocol._multi_request_batch_size = 5 + assert dummy_protocol._multi_request_batch_size == 5 + await dummy_protocol.query(requests, retry_count=1) + assert dummy_protocol._multi_request_batch_size == 1 + # Call count should be the first error + number of requests + assert send_mock.call_count == len(requests) + 1 + + +async def test_smart_device_multiple_request_json_decode_failure_twice( + dummy_protocol, mocker +): + """Test the logic to disable multiple requests on JSON_DECODE_FAIL_ERROR.""" + requests = {} + + mock_json_error = { + "result": {"responses": []}, + "error_code": SmartErrorCode.JSON_DECODE_FAIL_ERROR.value, + } + for i in range(10): + method = f"get_method_{i}" + requests[method] = {"foo": "bar", "bar": "foo"} + + send_mock = mocker.patch.object( + dummy_protocol._transport, + "send", + side_effect=[mock_json_error, KasaException], + ) + dummy_protocol._multi_request_batch_size = 5 + with pytest.raises(KasaException): + await dummy_protocol.query(requests, retry_count=1) + assert dummy_protocol._multi_request_batch_size == 1 + + assert send_mock.call_count == 2 + + +async def test_smart_device_multiple_request_non_json_decode_failure( + dummy_protocol, mocker +): + """Test the logic to disable multiple requests on JSON_DECODE_FAIL_ERROR. + + Ensure other exception types behave as expected. + """ + requests = {} + + mock_json_error = { + "result": {"responses": []}, + "error_code": SmartErrorCode.UNKNOWN_METHOD_ERROR.value, + } + for i in range(10): + method = f"get_method_{i}" + requests[method] = {"foo": "bar", "bar": "foo"} + + send_mock = mocker.patch.object( + dummy_protocol._transport, + "send", + side_effect=[mock_json_error, KasaException], + ) + dummy_protocol._multi_request_batch_size = 5 + with pytest.raises(DeviceError): + await dummy_protocol.query(requests, retry_count=1) + assert dummy_protocol._multi_request_batch_size == 5 + + assert send_mock.call_count == 1 + + +async def test_childdevicewrapper_unwrapping(dummy_protocol, mocker): + """Test that responseData gets unwrapped correctly.""" + wrapped_protocol = _ChildProtocolWrapper("dummyid", dummy_protocol) + mock_response = {"error_code": 0, "result": {"responseData": {"error_code": 0}}} + + mocker.patch.object(wrapped_protocol._transport, "send", return_value=mock_response) + res = await wrapped_protocol.query(DUMMY_QUERY) + assert res == {"foobar": None} + + +async def test_childdevicewrapper_unwrapping_with_payload(dummy_protocol, mocker): + wrapped_protocol = _ChildProtocolWrapper("dummyid", dummy_protocol) + mock_response = { + "error_code": 0, + "result": {"responseData": {"error_code": 0, "result": {"bar": "bar"}}}, + } + mocker.patch.object(wrapped_protocol._transport, "send", return_value=mock_response) + res = await wrapped_protocol.query(DUMMY_QUERY) + assert res == {"foobar": {"bar": "bar"}} + + +async def test_childdevicewrapper_error(dummy_protocol, mocker): + """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}}} + + mocker.patch.object(wrapped_protocol._transport, "send", return_value=mock_response) + with pytest.raises(KasaException): + await wrapped_protocol.query(DUMMY_QUERY) + + +async def test_childdevicewrapper_unwrapping_multiplerequest(dummy_protocol, mocker): + """Test that unwrapping multiplerequest works correctly.""" + mock_response = { + "error_code": 0, + "result": { + "responseData": { + "result": { + "responses": [ + { + "error_code": 0, + "method": "get_device_info", + "result": {"foo": "bar"}, + }, + { + "error_code": 0, + "method": "second_command", + "result": {"bar": "foo"}, + }, + ] + } + } + }, + } + wrapped_protocol = _ChildProtocolWrapper("dummyid", dummy_protocol) + mocker.patch.object(wrapped_protocol._transport, "send", return_value=mock_response) + resp = await wrapped_protocol.query(DUMMY_QUERY) + assert resp == {"get_device_info": {"foo": "bar"}, "second_command": {"bar": "foo"}} + + +async def test_childdevicewrapper_multiplerequest_error(dummy_protocol, mocker): + """Test that errors inside multipleRequest response of responseData raise an exception.""" + mock_response = { + "error_code": 0, + "result": { + "responseData": { + "result": { + "responses": [ + { + "error_code": 0, + "method": "get_device_info", + "result": {"foo": "bar"}, + }, + {"error_code": -1001, "method": "invalid_command"}, + ] + } + } + }, + } + wrapped_protocol = _ChildProtocolWrapper("dummyid", dummy_protocol) + mocker.patch.object(wrapped_protocol._transport, "send", return_value=mock_response) + res = await wrapped_protocol.query(DUMMY_QUERY) + assert res["get_device_info"] == {"foo": "bar"} + assert res["invalid_command"] == SmartErrorCode(-1001) + + +@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): + child_device_list = [{"foo": i} for i in range(list_sum)] + response = { + "get_child_device_list": { + "child_device_list": child_device_list, + "start_index": 0, + "sum": list_sum, + } + } + request = {"get_child_device_list": None} + + ft = FakeSmartTransport( + response, + "foobar", + list_return_size=batch_size, + component_nego_not_included=True, + get_child_fixtures=False, + ) + protocol = SmartProtocol(transport=ft) + query_spy = mocker.spy(protocol, "_execute_query") + resp = await protocol.query(request) + expected_count = int(list_sum / batch_size) + (1 if list_sum % batch_size else 0) + assert query_spy.call_count == expected_count + assert resp == response + + +@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): + child_list = [{"foo": i} for i in range(list_sum)] + response = { + "get_child_device_list": { + "child_device_list": child_list, + "start_index": 0, + "sum": list_sum, + }, + "get_child_device_component_list": { + "child_component_list": child_list, + "start_index": 0, + "sum": list_sum, + }, + } + request = {"get_child_device_list": None, "get_child_device_component_list": None} + + ft = FakeSmartTransport( + response, + "foobar", + list_return_size=batch_size, + component_nego_not_included=True, + get_child_fixtures=False, + ) + protocol = SmartProtocol(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 + + +@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, list_sum, batch_size): + """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, caplog): + """Test for handling incomplete lists returned from queries.""" + info = { + "get_preset_rules": { + "start_index": 0, + "states": [ + { + "brightness": 50, + }, + { + "brightness": 100, + }, + ], + "sum": 7, + } + } + caplog.set_level(logging.ERROR) + transport = FakeSmartTransport( + info, + "dummy-name", + component_nego_not_included=True, + warn_fixture_missing_methods=False, + ) + protocol = SmartProtocol(transport=transport) + resp = await protocol.query({"get_preset_rules": None}) + assert resp + assert resp["get_preset_rules"]["sum"] == 2 # FakeTransport fixes sum + assert caplog.text == "" + + # Test behaviour without FakeTranport fix + transport = FakeSmartTransport( + info, + "dummy-name", + component_nego_not_included=True, + warn_fixture_missing_methods=False, + fix_incomplete_fixture_lists=False, + ) + protocol = SmartProtocol(transport=transport) + resp = await protocol.query({"get_preset_rules": None}) + assert resp["get_preset_rules"]["sum"] == 7 + assert ( + "Device 127.0.0.123 returned empty results list for method get_preset_rules" + in caplog.text + ) + + +@device_smart +@pytest.mark.xdist_group(name="caplog") +async def test_smart_queries_redaction( + dev: SmartDevice, caplog: pytest.LogCaptureFixture +): + """Test query sensitive info redaction.""" + if isinstance(dev.protocol._transport, FakeSmartTransport): + device_id = "123456789ABCDEF" + dev.protocol._transport.info["get_device_info"]["device_id"] = device_id + else: # real device + device_id = dev.device_id + + # Info no message logging + caplog.set_level(logging.INFO) + await dev.update() + assert device_id not in caplog.text + + caplog.set_level(logging.DEBUG) + + # Debug no redaction + caplog.clear() + dev.protocol._redact_data = False + await dev.update() + assert device_id in caplog.text + + # Debug redaction + caplog.clear() + dev.protocol._redact_data = True + 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 +): + """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 +): + """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/__init__.py b/tests/smart/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/smart/features/__init__.py b/tests/smart/features/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/smart/features/test_brightness.py b/tests/smart/features/test_brightness.py new file mode 100644 index 000000000..ff38854a8 --- /dev/null +++ b/tests/smart/features/test_brightness.py @@ -0,0 +1,58 @@ +import pytest + +from kasa.iot import IotDevice +from kasa.smart import SmartDevice + +from ...conftest import dimmable_iot, get_parent_and_child_modules, parametrize + +brightness = parametrize("brightness smart", component_filter="brightness") + + +@brightness +async def test_brightness_component(dev: SmartDevice): + """Test brightness feature.""" + brightness = next(get_parent_and_child_modules(dev, "Brightness")) + assert brightness + assert isinstance(dev, SmartDevice) + assert "brightness" in dev._components + + # Test getting the value + feature = brightness._device.features["brightness"] + assert isinstance(feature.value, int) + assert feature.value > 1 + assert feature.value <= 100 + + # Test setting the value + await feature.set_value(10) + await dev.update() + assert feature.value == 10 + + with pytest.raises(ValueError, match="out of range"): + await feature.set_value(feature.minimum_value - 10) + + with pytest.raises(ValueError, match="out of range"): + await feature.set_value(feature.maximum_value + 10) + + +@dimmable_iot +async def test_brightness_dimmable(dev: IotDevice): + """Test brightness feature.""" + assert isinstance(dev, IotDevice) + assert "brightness" in dev.sys_info or bool(dev.sys_info["is_dimmable"]) + + # Test getting the value + feature = dev.features["brightness"] + assert isinstance(feature.value, int) + assert feature.value > 0 + assert feature.value <= 100 + + # Test setting the value + await feature.set_value(10) + await dev.update() + assert feature.value == 10 + + with pytest.raises(ValueError, match="out of range"): + await feature.set_value(feature.minimum_value - 10) + + with pytest.raises(ValueError, match="out of range"): + await feature.set_value(feature.maximum_value + 10) diff --git a/tests/smart/features/test_colortemp.py b/tests/smart/features/test_colortemp.py new file mode 100644 index 000000000..055c5b299 --- /dev/null +++ b/tests/smart/features/test_colortemp.py @@ -0,0 +1,31 @@ +import pytest + +from kasa.smart import SmartDevice + +from ...conftest import variable_temp_smart + + +@variable_temp_smart +async def test_colortemp_component(dev: SmartDevice): + """Test brightness feature.""" + assert isinstance(dev, SmartDevice) + assert "color_temperature" in dev._components + + # Test getting the value + feature = dev.features["color_temperature"] + assert isinstance(feature.value, int) + assert isinstance(feature.minimum_value, int) + assert isinstance(feature.maximum_value, int) + + # Test setting the value + # We need to take the min here, as L9xx reports a range [9000, 9000]. + new_value = min(feature.minimum_value + 1, feature.maximum_value) + await feature.set_value(new_value) + await dev.update() + assert feature.value == new_value + + with pytest.raises(ValueError, match="out of range"): + await feature.set_value(feature.minimum_value - 10) + + with pytest.raises(ValueError, match="out of range"): + await feature.set_value(feature.maximum_value + 10) diff --git a/tests/smart/modules/__init__.py b/tests/smart/modules/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/smart/modules/test_alarm.py b/tests/smart/modules/test_alarm.py new file mode 100644 index 000000000..25d24a588 --- /dev/null +++ b/tests/smart/modules/test_alarm.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import pytest +from pytest_mock import MockerFixture + +from kasa import Module +from kasa.smart import SmartDevice +from kasa.smart.modules import Alarm + +from ...device_fixtures import get_parent_and_child_modules, parametrize + +alarm = parametrize("has alarm", component_filter="alarm", protocol_filter={"SMART"}) + + +@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): + """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): + """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, request_params, mocker: MockerFixture): + """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): + """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, +): + """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 new file mode 100644 index 000000000..9bdf9e564 --- /dev/null +++ b/tests/smart/modules/test_autooff.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from datetime import datetime + +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 + +autooff = parametrize( + "has autooff", component_filter="auto_off", protocol_filter={"SMART"} +) + + +@autooff +@pytest.mark.parametrize( + ("feature", "prop_name", "type"), + [ + ("auto_off_enabled", "enabled", bool), + ("auto_off_minutes", "delay", int), + ("auto_off_at", "auto_off_at", datetime | None), + ], +) +async def test_autooff_features( + dev: SmartDevice, feature: str, prop_name: str, type: type +): + """Test that features are registered and work as expected.""" + autooff = next(get_parent_and_child_modules(dev, Module.AutoOff)) + assert autooff is not None + + prop = getattr(autooff, prop_name) + assert isinstance(prop, type) + + feat = autooff._device.features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) + + +@autooff +async def test_settings(dev: SmartDevice, mocker: MockerFixture): + """Test autooff settings.""" + autooff = next(get_parent_and_child_modules(dev, Module.AutoOff)) + assert autooff + + enabled = autooff._device.features["auto_off_enabled"] + assert autooff.enabled == enabled.value + + delay = autooff._device.features["auto_off_minutes"] + assert autooff.delay == delay.value + + call = mocker.spy(autooff, "call") + new_state = True + + await autooff.set_enabled(new_state) + call.assert_called_with( + "set_auto_off_config", {"enable": new_state, "delay_min": delay.value} + ) + call.reset_mock() + await dev.update() + + new_delay = 123 + + await autooff.set_delay(new_delay) + + call.assert_called_with( + "set_auto_off_config", {"enable": new_state, "delay_min": new_delay} + ) + + await dev.update() + + assert autooff.enabled == new_state + assert autooff.delay == new_delay + + +@autooff +@pytest.mark.parametrize("is_timer_active", [True, False]) +async def test_auto_off_at( + dev: SmartDevice, mocker: MockerFixture, is_timer_active: bool +): + """Test auto-off at sensor.""" + autooff = next(get_parent_and_child_modules(dev, Module.AutoOff)) + assert autooff + + autooff_at = autooff._device.features["auto_off_at"] + + mocker.patch.object( + type(autooff), + "is_timer_active", + new_callable=mocker.PropertyMock, + return_value=is_timer_active, + ) + if is_timer_active: + assert isinstance(autooff_at.value, datetime) + else: + assert autooff_at.value is None diff --git a/tests/smart/modules/test_childlock.py b/tests/smart/modules/test_childlock.py new file mode 100644 index 000000000..2ffa91045 --- /dev/null +++ b/tests/smart/modules/test_childlock.py @@ -0,0 +1,44 @@ +import pytest + +from kasa import Module +from kasa.smart.modules import ChildLock + +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, feature, prop_name, type): + """Test that features are registered and work as expected.""" + protect: ChildLock = 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): + """Test the API.""" + protect: ChildLock = 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 new file mode 100644 index 000000000..ad2878e57 --- /dev/null +++ b/tests/smart/modules/test_childprotection.py @@ -0,0 +1,44 @@ +import pytest + +from kasa import Module +from kasa.smart.modules import ChildProtection + +from ...device_fixtures import parametrize + +child_protection = parametrize( + "has child protection", + component_filter="child_protection", + protocol_filter={"SMART.CHILD"}, +) + + +@child_protection +@pytest.mark.parametrize( + ("feature", "prop_name", "type"), + [ + ("child_lock", "enabled", bool), + ], +) +async def test_features(dev, feature, prop_name, type): + """Test that features are registered and work as expected.""" + protect: ChildProtection = dev.modules[Module.ChildProtection] + 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) + + +@child_protection +async def test_enabled(dev): + """Test the API.""" + protect: ChildProtection = dev.modules[Module.ChildProtection] + 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_childsetup.py b/tests/smart/modules/test_childsetup.py new file mode 100644 index 000000000..afe36b0c0 --- /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): + """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 +): + """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 +): + """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..0f935959e --- /dev/null +++ b/tests/smart/modules/test_clean.py @@ -0,0 +1,241 @@ +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 ErrorCode, 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): + """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, +): + """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, +): + """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): + """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 +): + """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, +): + """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) diff --git a/tests/smart/modules/test_cleanrecords.py b/tests/smart/modules/test_cleanrecords.py new file mode 100644 index 000000000..cef692868 --- /dev/null +++ b/tests/smart/modules/test_cleanrecords.py @@ -0,0 +1,59 @@ +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): + """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): + """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..7a28f3be9 --- /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): + """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 +): + """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 new file mode 100644 index 000000000..c5c4c935f --- /dev/null +++ b/tests/smart/modules/test_contact.py @@ -0,0 +1,29 @@ +import pytest + +from kasa import Device, Module + +from ...device_fixtures import parametrize + +contact = parametrize( + "is contact sensor", model_filter="T110", protocol_filter={"SMART.CHILD"} +) + + +@contact +@pytest.mark.parametrize( + ("feature", "type"), + [ + ("is_open", bool), + ], +) +async def test_contact_features(dev: Device, feature, type): + """Test that features are registered and work as expected.""" + contact = dev.modules.get(Module.ContactSensor) + assert contact is not None + + prop = getattr(contact, feature) + assert isinstance(prop, type) + + feat = dev.features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) diff --git a/tests/smart/modules/test_dustbin.py b/tests/smart/modules/test_dustbin.py new file mode 100644 index 000000000..ecc68b6b2 --- /dev/null +++ b/tests/smart/modules/test_dustbin.py @@ -0,0 +1,111 @@ +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): + """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): + """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): + """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): + """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): + """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..7b31d74bf --- /dev/null +++ b/tests/smart/modules/test_energy.py @@ -0,0 +1,109 @@ +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 tests.conftest import has_emeter_smart + + +@has_emeter_smart +async def test_supported(dev: SmartDevice): + 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) + 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 +): + """Test errors on get_energy_usage.""" + caplog.set_level(logging.DEBUG) + + energy_module = dev.modules.get(Module.Energy) + if not energy_module: + pytest.skip(f"Energy module not supported for {dev}.") + + version = dev._components["energy_monitoring"] + + 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 + + last_update = copy.deepcopy(dev._last_update) + resp = copy.deepcopy(last_update) + + if ed := resp.get("get_emeter_data"): + ed["power_mw"] = 2002 + if cp := resp.get("get_current_power"): + cp["current_power"] = 2.002 + 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 + + # Now test with no get_emeter_data + # This may not be valid scenario but we have a fallback to get_current_power + # just in case that should be tested. + caplog.clear() + resp = copy.deepcopy(last_update) + + if cp := resp.get("get_current_power"): + cp["current_power"] = 2.002 + resp["get_energy_usage"] = SmartErrorCode.JSON_DECODE_FAIL_ERROR + + # Remove get_emeter_data from the response and from the device which will + # remember it otherwise. + resp.pop("get_emeter_data", None) + dev._last_update.pop("get_emeter_data", None) + + 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 + + # message should only be logged once + assert msg not in caplog.text diff --git a/tests/smart/modules/test_fan.py b/tests/smart/modules/test_fan.py new file mode 100644 index 000000000..5f505e747 --- /dev/null +++ b/tests/smart/modules/test_fan.py @@ -0,0 +1,119 @@ +import pytest +from pytest_mock import MockerFixture + +from kasa import KasaException, Module +from kasa.smart import SmartDevice +from kasa.smart.modules import Fan + +from ...device_fixtures import get_parent_and_child_modules, parametrize + +fan = parametrize("has fan", component_filter="fan_control", protocol_filter={"SMART"}) + + +@fan +async def test_fan_speed(dev: SmartDevice, mocker: MockerFixture): + """Test fan speed feature.""" + fan = next(get_parent_and_child_modules(dev, Module.Fan)) + assert fan + level_feature = fan._module_features["fan_speed_level"] + assert ( + level_feature.minimum_value + <= level_feature.value + <= level_feature.maximum_value + ) + + call = mocker.spy(fan, "call") + await fan.set_fan_speed_level(3) + call.assert_called_with( + "set_device_info", {"device_on": True, "fan_speed_level": 3} + ) + + await dev.update() + + assert fan.fan_speed_level == 3 + assert level_feature.value == 3 + + +@fan +async def test_sleep_mode(dev: SmartDevice, mocker: MockerFixture): + """Test sleep mode feature.""" + fan = next(get_parent_and_child_modules(dev, Module.Fan)) + assert fan + sleep_feature = fan._module_features["fan_sleep_mode"] + assert isinstance(sleep_feature.value, bool) + + call = mocker.spy(fan, "call") + await fan.set_sleep_mode(True) + call.assert_called_with("set_device_info", {"fan_sleep_mode_on": True}) + + await dev.update() + + assert fan.sleep_mode is True + assert sleep_feature.value is True + + +@fan +async def test_fan_module(dev: SmartDevice, mocker: MockerFixture): + """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 + + await fan.set_fan_speed_level(1) + await dev.update() + assert fan.fan_speed_level == 1 + assert device.is_on + + # Check that if the device is off the speed level is 0. + await device.set_state(False) + await dev.update() + assert fan.fan_speed_level == 0 + + await fan.set_fan_speed_level(4) + await dev.update() + assert fan.fan_speed_level == 4 + + await fan.set_fan_speed_level(0) + await dev.update() + assert not device.is_on + + fan_speed_level_feature = fan._module_features["fan_speed_level"] + max_level = fan_speed_level_feature.maximum_value + min_level = fan_speed_level_feature.minimum_value + with pytest.raises(ValueError, match="Invalid level"): + await fan.set_fan_speed_level(min_level - 1) + + with pytest.raises(ValueError, match="Invalid level"): + await fan.set_fan_speed_level(max_level - 5) + + +@fan +async def test_fan_features(dev: SmartDevice, mocker: MockerFixture): + """Test fan speed on device interface.""" + assert isinstance(dev, SmartDevice) + fan = next(get_parent_and_child_modules(dev, Module.Fan)) + assert fan + expected_feature = fan._module_features["fan_speed_level"] + + fan_speed_level_feature = fan.get_feature(Fan.set_fan_speed_level) + assert expected_feature == fan_speed_level_feature + + fan_speed_level_feature = fan.get_feature(fan.set_fan_speed_level) + assert expected_feature == fan_speed_level_feature + + fan_speed_level_feature = fan.get_feature(Fan.fan_speed_level) + assert expected_feature == fan_speed_level_feature + + fan_speed_level_feature = fan.get_feature("fan_speed_level") + assert expected_feature == fan_speed_level_feature + + assert fan.has_feature(Fan.fan_speed_level) + + msg = "Attribute _check_supported of module Fan is not bound to a feature" + with pytest.raises(KasaException, match=msg): + assert fan.has_feature(fan._check_supported) + + msg = "No attribute named foobar in module Fan" + with pytest.raises(KasaException, match=msg): + assert fan.has_feature("foobar") diff --git a/tests/smart/modules/test_firmware.py b/tests/smart/modules/test_firmware.py new file mode 100644 index 000000000..e3fe5bb36 --- /dev/null +++ b/tests/smart/modules/test_firmware.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import asyncio +import logging +from contextlib import nullcontext +from datetime import date +from typing import TypedDict + +import pytest +from pytest_mock import MockerFixture + +from kasa import KasaException, Module +from kasa.smart import SmartDevice +from kasa.smart.modules.firmware import DownloadState + +from ...device_fixtures import parametrize + +firmware = parametrize( + "has firmware", component_filter="firmware", protocol_filter={"SMART"} +) + + +@firmware +@pytest.mark.parametrize( + ("feature", "prop_name", "type", "required_version"), + [ + ("auto_update_enabled", "auto_update_enabled", bool, 2), + ("update_available", "update_available", bool, 1), + ("current_firmware_version", "current_firmware", str, 1), + ("available_firmware_version", "latest_firmware", str, 1), + ], +) +async def test_firmware_features( + dev: SmartDevice, feature, prop_name, type, required_version, mocker: MockerFixture +): + """Test light effect.""" + fw = dev.modules.get(Module.Firmware) + assert fw + assert fw.firmware_update_info is None + + if not dev.is_cloud_connected: + pytest.skip("Device is not cloud connected, skipping test") + + await fw.check_latest_firmware() + if fw.supported_version < required_version: + pytest.skip(f"Feature {feature} requires newer version") + + prop = getattr(fw, prop_name) + assert isinstance(prop, type) + + feat = dev.features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) + + +@firmware +async def test_firmware_update_info(dev: SmartDevice): + """Test that the firmware UpdateInfo object deserializes correctly.""" + fw = dev.modules.get(Module.Firmware) + assert fw + + if not dev.is_cloud_connected: + pytest.skip("Device is not cloud connected, skipping test") + assert fw.firmware_update_info is None + await fw.check_latest_firmware() + assert fw.firmware_update_info is not None + assert isinstance(fw.firmware_update_info.release_date, date | None) + + +@firmware +async def test_update_available_without_cloud(dev: SmartDevice): + """Test that update_available returns None when disconnected.""" + fw = dev.modules.get(Module.Firmware) + assert fw + assert fw.firmware_update_info is None + + if dev.is_cloud_connected: + await fw.check_latest_firmware() + assert isinstance(fw.update_available, bool) + else: + assert fw.update_available is None + + +@firmware +@pytest.mark.parametrize( + ("update_available", "expected_result"), + [ + pytest.param(True, nullcontext(), id="available"), + pytest.param(False, pytest.raises(KasaException), id="not-available"), + ], +) +@pytest.mark.requires_dummy +@pytest.mark.xdist_group(name="caplog") +async def test_firmware_update( + dev: SmartDevice, + mocker: MockerFixture, + caplog: pytest.LogCaptureFixture, + update_available, + expected_result, +): + """Test updating firmware.""" + caplog.set_level(logging.INFO) + + if not dev.is_cloud_connected: + pytest.skip("Device is not cloud connected, skipping test") + + fw = dev.modules.get(Module.Firmware) + assert fw + + upgrade_time = 5 + + class Extras(TypedDict): + reboot_time: int + upgrade_time: int + auto_upgrade: bool + + extras: Extras = { + "reboot_time": 5, + "upgrade_time": upgrade_time, + "auto_upgrade": False, + } + update_states = [ + # Unknown 1 + DownloadState(status=1, progress=0, **extras), + # Downloading + DownloadState(status=2, progress=10, **extras), + DownloadState(status=2, progress=100, **extras), + # Flashing + DownloadState(status=3, progress=100, **extras), + DownloadState(status=3, progress=100, **extras), + # Done + DownloadState(status=0, progress=100, **extras), + ] + + asyncio_sleep = asyncio.sleep + sleep = mocker.patch("asyncio.sleep") + mocker.patch.object(fw, "get_update_state", side_effect=update_states) + + cb_mock = mocker.AsyncMock() + + assert fw.firmware_update_info is None + with pytest.raises(KasaException): + await fw.update(progress_cb=cb_mock) + await fw.check_latest_firmware() + assert fw.firmware_update_info is not None + + fw._firmware_update_info.status = 1 if update_available else 0 + + with expected_result: + await fw.update(progress_cb=cb_mock) + + if not update_available: + return + + # This is necessary to allow the eventloop to process the created tasks + await asyncio_sleep(0) + + assert "Unhandled state code" in caplog.text + assert "Downloading firmware, progress: 10" in caplog.text + assert "Flashing firmware, sleeping" in caplog.text + assert "Update idle" in caplog.text + + for state in update_states: + cb_mock.assert_any_await(state) + + # sleep based on the upgrade_time + sleep.assert_any_call(upgrade_time) diff --git a/tests/smart/modules/test_homekit.py b/tests/smart/modules/test_homekit.py new file mode 100644 index 000000000..819923986 --- /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): + """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 new file mode 100644 index 000000000..5e14a05b4 --- /dev/null +++ b/tests/smart/modules/test_humidity.py @@ -0,0 +1,29 @@ +import pytest + +from kasa.smart.modules import HumiditySensor + +from ...device_fixtures import parametrize + +humidity = parametrize( + "has humidity", component_filter="humidity", protocol_filter={"SMART.CHILD"} +) + + +@humidity +@pytest.mark.parametrize( + ("feature", "type"), + [ + ("humidity", int), + ("humidity_warning", bool), + ], +) +async def test_humidity_features(dev, feature, type): + """Test that features are registered and work as expected.""" + humidity: HumiditySensor = dev.modules["HumiditySensor"] + + prop = getattr(humidity, feature) + assert isinstance(prop, type) + + feat = dev.features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) diff --git a/tests/smart/modules/test_light_effect.py b/tests/smart/modules/test_light_effect.py new file mode 100644 index 000000000..e4475652c --- /dev/null +++ b/tests/smart/modules/test_light_effect.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from itertools import chain + +import pytest +from pytest_mock import MockerFixture + +from kasa import Device, Feature, Module +from kasa.smart.modules import LightEffect + +from ...device_fixtures import parametrize + +light_effect = parametrize( + "has light effect", component_filter="light_effect", protocol_filter={"SMART"} +) + + +@light_effect +async def test_light_effect(dev: Device, mocker: MockerFixture): + """Test light effect.""" + light_effect = dev.modules.get(Module.LightEffect) + assert isinstance(light_effect, LightEffect) + + feature = dev.features["light_effect"] + assert feature.type == Feature.Type.Choice + + call = mocker.spy(light_effect, "call") + assert feature.choices == light_effect.effect_list + assert feature.choices + for effect in chain(reversed(feature.choices), feature.choices): + await light_effect.set_effect(effect) + enable = effect != LightEffect.LIGHT_EFFECTS_OFF + params: dict[str, bool | str] = {"enable": enable} + if enable: + params["id"] = light_effect._scenes_names_to_id[effect] + call.assert_called_with("set_dynamic_light_effect_rule_enable", params) + await dev.update() + assert light_effect.effect == effect + assert feature.value == effect + + with pytest.raises(ValueError, match="The effect foobar is not a built in effect"): + await light_effect.set_effect("foobar") + + +@light_effect +@pytest.mark.parametrize("effect_active", [True, False]) +async def test_light_effect_brightness( + dev: Device, effect_active: bool, mocker: MockerFixture +): + """Test that light module uses light_effect for brightness when active.""" + light_module = dev.modules[Module.Light] + + light_effect = dev.modules[Module.SmartLightEffect] + light_effect_set_brightness = mocker.spy(light_effect, "set_brightness") + mock_light_effect_call = mocker.patch.object(light_effect, "call") + + brightness = dev.modules[Module.Brightness] + brightness_set_brightness = mocker.spy(brightness, "set_brightness") + mock_brightness_call = mocker.patch.object(brightness, "call") + + mocker.patch.object( + type(light_effect), + "is_active", + new_callable=mocker.PropertyMock, + return_value=effect_active, + ) + if effect_active: # Set the rule L1 active for testing + light_effect.data["current_rule_id"] = "L1" + + await light_module.set_brightness(10) + + if effect_active: + assert light_effect.is_active + assert light_effect.brightness == light_module.brightness + + light_effect_set_brightness.assert_called_with(10) + mock_light_effect_call.assert_called_with( + "edit_dynamic_light_effect_rule", mocker.ANY + ) + else: + assert not light_effect.is_active + + brightness_set_brightness.assert_called_with(10) + mock_brightness_call.assert_called_with("set_device_info", {"brightness": 10}) diff --git a/tests/smart/modules/test_light_strip_effect.py b/tests/smart/modules/test_light_strip_effect.py new file mode 100644 index 000000000..81bc35c83 --- /dev/null +++ b/tests/smart/modules/test_light_strip_effect.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from itertools import chain + +import pytest +from pytest_mock import MockerFixture + +from kasa import Device, Feature, Module +from kasa.smart.modules import LightEffect, LightStripEffect + +from ...device_fixtures import parametrize + +light_strip_effect = parametrize( + "has light strip effect", + component_filter="light_strip_lighting_effect", + protocol_filter={"SMART"}, +) + + +@light_strip_effect +async def test_light_strip_effect(dev: Device, mocker: MockerFixture): + """Test light strip effect.""" + light_effect = dev.modules.get(Module.LightEffect) + + assert isinstance(light_effect, LightStripEffect) + + brightness = dev.modules[Module.Brightness] + + feature = dev.features["light_effect"] + assert feature.type == Feature.Type.Choice + + call = mocker.spy(light_effect, "call") + + assert feature.choices == light_effect.effect_list + assert feature.choices + for effect in chain(reversed(feature.choices), feature.choices): + if effect == LightEffect.LIGHT_EFFECTS_OFF: + off_effect = ( + light_effect.effect + if light_effect.effect in light_effect._effect_mapping + else "Aurora" + ) + await light_effect.set_effect(effect) + + if effect != LightEffect.LIGHT_EFFECTS_OFF: + params = {**light_effect._effect_mapping[effect]} + else: + params = {**light_effect._effect_mapping[off_effect]} + params["enable"] = 0 + params["brightness"] = brightness.brightness # use the existing brightness + + call.assert_called_with("set_lighting_effect", params) + + await dev.update() + assert light_effect.effect == effect + assert feature.value == effect + + with pytest.raises(ValueError, match="The effect foobar is not a built in effect"): + await light_effect.set_effect("foobar") + + +@light_strip_effect +@pytest.mark.parametrize("effect_active", [True, False]) +async def test_light_effect_brightness( + dev: Device, effect_active: bool, mocker: MockerFixture +): + """Test that light module uses light_effect for brightness when active.""" + light_module = dev.modules[Module.Light] + + light_effect = dev.modules[Module.SmartLightEffect] + light_effect_set_brightness = mocker.spy(light_effect, "set_brightness") + mock_light_effect_call = mocker.patch.object(light_effect, "call") + + brightness = dev.modules[Module.Brightness] + brightness_set_brightness = mocker.spy(brightness, "set_brightness") + mock_brightness_call = mocker.patch.object(brightness, "call") + + mocker.patch.object( + type(light_effect), + "is_active", + new_callable=mocker.PropertyMock, + return_value=effect_active, + ) + + await light_module.set_brightness(10) + + if effect_active: + assert light_effect.is_active + assert light_effect.brightness == light_module.brightness + + light_effect_set_brightness.assert_called_with(10) + mock_light_effect_call.assert_called_with( + "set_lighting_effect", {"brightness": 10, "bAdjusted": True} + ) + else: + assert not light_effect.is_active + + brightness_set_brightness.assert_called_with(10) + mock_brightness_call.assert_called_with("set_device_info", {"brightness": 10}) diff --git a/tests/smart/modules/test_lighttransition.py b/tests/smart/modules/test_lighttransition.py new file mode 100644 index 000000000..c1b805e48 --- /dev/null +++ b/tests/smart/modules/test_lighttransition.py @@ -0,0 +1,81 @@ +from pytest_mock import MockerFixture + +from kasa import Feature, Module +from kasa.smart import SmartDevice + +from ...device_fixtures import get_parent_and_child_modules, parametrize +from ...fixtureinfo import ComponentFilter + +light_transition_v1 = parametrize( + "has light transition", + component_filter=ComponentFilter( + component_name="on_off_gradually", maximum_version=1 + ), + protocol_filter={"SMART"}, +) +light_transition_gt_v1 = parametrize( + "has light transition", + component_filter=ComponentFilter( + component_name="on_off_gradually", minimum_version=2 + ), + protocol_filter={"SMART"}, +) + + +@light_transition_v1 +async def test_module_v1(dev: SmartDevice, mocker: MockerFixture): + """Test light transition module.""" + assert isinstance(dev, SmartDevice) + light_transition = next(get_parent_and_child_modules(dev, Module.LightTransition)) + assert light_transition + assert "smooth_transitions" in light_transition._module_features + assert "smooth_transition_on" not in light_transition._module_features + assert "smooth_transition_off" not in light_transition._module_features + + await light_transition.set_enabled(True) + await dev.update() + assert light_transition.enabled is True + + await light_transition.set_enabled(False) + await dev.update() + assert light_transition.enabled is False + + +@light_transition_gt_v1 +async def test_module_gt_v1(dev: SmartDevice, mocker: MockerFixture): + """Test light transition module.""" + assert isinstance(dev, SmartDevice) + light_transition = next(get_parent_and_child_modules(dev, Module.LightTransition)) + assert light_transition + assert "smooth_transitions" not in light_transition._module_features + assert "smooth_transition_on" in light_transition._module_features + assert "smooth_transition_off" in light_transition._module_features + + await light_transition.set_enabled(True) + await dev.update() + assert light_transition.enabled is True + + await light_transition.set_enabled(False) + await dev.update() + assert light_transition.enabled is False + + await light_transition.set_turn_on_transition(5) + await dev.update() + assert light_transition.turn_on_transition == 5 + # enabled is true if either on or off is enabled + assert light_transition.enabled is True + + await light_transition.set_turn_off_transition(10) + await dev.update() + assert light_transition.turn_off_transition == 10 + assert light_transition.enabled is True + + max_on = light_transition._module_features["smooth_transition_on"].maximum_value + assert max_on < Feature.DEFAULT_MAX + max_off = light_transition._module_features["smooth_transition_off"].maximum_value + assert max_off < Feature.DEFAULT_MAX + + await light_transition.set_turn_on_transition(0) + await light_transition.set_turn_off_transition(0) + await dev.update() + assert light_transition.enabled is False diff --git a/tests/smart/modules/test_matter.py b/tests/smart/modules/test_matter.py new file mode 100644 index 000000000..d3ff80730 --- /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): + """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..b6492aa3a --- /dev/null +++ b/tests/smart/modules/test_mop.py @@ -0,0 +1,57 @@ +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): + """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): + """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 new file mode 100644 index 000000000..418ad51a1 --- /dev/null +++ b/tests/smart/modules/test_motionsensor.py @@ -0,0 +1,29 @@ +import pytest + +from kasa import Device, Module + +from ...device_fixtures import parametrize + +motion = parametrize( + "is motion sensor", model_filter="T100", protocol_filter={"SMART.CHILD"} +) + + +@motion +@pytest.mark.parametrize( + ("feature", "type"), + [ + ("motion_detected", bool), + ], +) +async def test_motion_features(dev: Device, feature, type): + """Test that features are registered and work as expected.""" + motion = dev.modules.get(Module.MotionSensor) + assert motion is not None + + prop = getattr(motion, feature) + assert isinstance(prop, type) + + feat = dev.features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) diff --git a/tests/smart/modules/test_powerprotection.py b/tests/smart/modules/test_powerprotection.py new file mode 100644 index 000000000..215df2be5 --- /dev/null +++ b/tests/smart/modules/test_powerprotection.py @@ -0,0 +1,106 @@ +import pytest +from pytest_mock import MockerFixture + +from kasa import Device, Module + +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, feature, prop_name, type): + """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): + """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): + """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..e11741da0 --- /dev/null +++ b/tests/smart/modules/test_speaker.py @@ -0,0 +1,71 @@ +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): + """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): + """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): + """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 new file mode 100644 index 000000000..c2f91ae1d --- /dev/null +++ b/tests/smart/modules/test_temperature.py @@ -0,0 +1,48 @@ +import pytest + +from kasa.smart.modules import TemperatureSensor + +from ...device_fixtures import parametrize + +temperature = parametrize( + "has temperature", component_filter="temperature", protocol_filter={"SMART.CHILD"} +) + +temperature_warning = parametrize( + "has temperature warning", + component_filter="comfort_temperature", + protocol_filter={"SMART.CHILD"}, +) + + +@temperature +@pytest.mark.parametrize( + ("feature", "type"), + [ + ("temperature", float), + ("temperature_unit", str), + ], +) +async def test_temperature_features(dev, feature, type): + """Test that features are registered and work as expected.""" + temp_module: TemperatureSensor = dev.modules["TemperatureSensor"] + + prop = getattr(temp_module, feature) + assert isinstance(prop, type) + + feat = dev.features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) + + +@temperature_warning +async def test_temperature_warning(dev): + """Test that features are registered and work as expected.""" + temp_module: TemperatureSensor = dev.modules["TemperatureSensor"] + + assert hasattr(temp_module, "temperature_warning") + assert isinstance(temp_module.temperature_warning, bool) + + feat = dev.features["temperature_warning"] + assert feat.value == temp_module.temperature_warning + assert isinstance(feat.value, bool) diff --git a/tests/smart/modules/test_temperaturecontrol.py b/tests/smart/modules/test_temperaturecontrol.py new file mode 100644 index 000000000..4bcf218fd --- /dev/null +++ b/tests/smart/modules/test_temperaturecontrol.py @@ -0,0 +1,166 @@ +import logging +import re + +import pytest + +from kasa.smart.modules import TemperatureControl +from kasa.smart.modules.temperaturecontrol import ThermostatState + +from ...device_fixtures import parametrize, thermostats_smart + +temperature = parametrize( + "has temperature control", + component_filter="temperature_control", + protocol_filter={"SMART.CHILD"}, +) + + +@thermostats_smart +@pytest.mark.parametrize( + ("feature", "type"), + [ + ("target_temperature", float), + ("temperature_offset", int), + ], +) +async def test_temperature_control_features(dev, feature, type): + """Test that features are registered and work as expected.""" + temp_module: TemperatureControl = dev.modules["TemperatureControl"] + + prop = getattr(temp_module, feature) + assert isinstance(prop, type) + + feat = dev.features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) + + await feat.set_value(10) + await dev.update() + assert feat.value == 10 + + +@thermostats_smart +async def test_set_temperature_turns_heating_on(dev): + """Test that set_temperature turns heating on.""" + temp_module: TemperatureControl = dev.modules["TemperatureControl"] + + await temp_module.set_state(False) + await dev.update() + assert temp_module.state is False + assert temp_module.mode is ThermostatState.Off + + await temp_module.set_target_temperature(10) + await dev.update() + assert temp_module.state is True + assert temp_module.mode is ThermostatState.Heating + assert temp_module.target_temperature == 10 + + +@thermostats_smart +async def test_set_temperature_invalid_values(dev): + """Test that out-of-bounds temperature values raise errors.""" + temp_module: TemperatureControl = dev.modules["TemperatureControl"] + + with pytest.raises( + ValueError, match="Invalid target temperature -1, must be in range" + ): + await temp_module.set_target_temperature(-1) + + with pytest.raises( + ValueError, match="Invalid target temperature 100, must be in range" + ): + await temp_module.set_target_temperature(100) + + +@thermostats_smart +async def test_temperature_offset(dev): + """Test the temperature offset API.""" + temp_module: TemperatureControl = dev.modules["TemperatureControl"] + with pytest.raises( + ValueError, match=re.escape("Temperature offset must be [-10, 10]") + ): + await temp_module.set_temperature_offset(100) + + with pytest.raises( + ValueError, match=re.escape("Temperature offset must be [-10, 10]") + ): + await temp_module.set_temperature_offset(-100) + + await temp_module.set_temperature_offset(5) + await dev.update() + assert temp_module.temperature_offset == 5 + + +@thermostats_smart +@pytest.mark.parametrize( + ("mode", "states", "frost_protection"), + [ + pytest.param(ThermostatState.Idle, [], False, id="idle has empty"), + pytest.param( + ThermostatState.Off, + ["anything"], + True, + id="any state with frost_protection on means off", + ), + pytest.param( + ThermostatState.Heating, + ["heating"], + False, + id="heating is heating", + ), + pytest.param(ThermostatState.Unknown, ["invalid"], False, id="unknown state"), + ], +) +async def test_thermostat_mode(dev, mode, states, frost_protection): + """Test different thermostat modes.""" + temp_module: TemperatureControl = dev.modules["TemperatureControl"] + + temp_module.data["frost_protection_on"] = frost_protection + temp_module.data["trv_states"] = states + + assert temp_module.state is not frost_protection + assert temp_module.mode is mode + + +@thermostats_smart +@pytest.mark.parametrize( + ("mode", "states", "msg"), + [ + pytest.param( + ThermostatState.Heating, + ["heating", "something else"], + "Got multiple states", + id="multiple states", + ), + pytest.param( + ThermostatState.Unknown, ["foobar"], "Got unknown state", id="unknown state" + ), + ], +) +@pytest.mark.xdist_group(name="caplog") +async def test_thermostat_mode_warnings(dev, mode, states, msg, caplog): + """Test thermostat modes that should log a warning.""" + temp_module: TemperatureControl = dev.modules["TemperatureControl"] + caplog.set_level(logging.WARNING) + + temp_module.data["trv_states"] = states + assert temp_module.mode is mode + assert msg in caplog.text + + +@thermostats_smart +async def test_thermostat_heating_with_low_battery(dev): + """Test that mode is reported correctly with extra states.""" + temp_module: TemperatureControl = dev.modules["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, caplog): + """Test that mode is reported correctly with extra states.""" + temp_module: TemperatureControl = dev.modules["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 new file mode 100644 index 000000000..c1d957217 --- /dev/null +++ b/tests/smart/modules/test_triggerlogs.py @@ -0,0 +1,22 @@ +from kasa import Device, Module + +from ...device_fixtures import parametrize + +triggerlogs = parametrize( + "has trigger_logs", + component_filter="trigger_log", + protocol_filter={"SMART", "SMART.CHILD"}, +) + + +@triggerlogs +async def test_trigger_logs(dev: Device): + """Test that features are registered and work as expected.""" + triggerlogs = dev.modules.get(Module.TriggerLogs) + assert triggerlogs is not None + if logs := triggerlogs.logs: + first = logs[0] + assert isinstance(first.id, int) + assert isinstance(first.timestamp, int) + assert isinstance(first.event, str) + assert isinstance(first.event_id, str) diff --git a/tests/smart/modules/test_waterleak.py b/tests/smart/modules/test_waterleak.py new file mode 100644 index 000000000..afae7dda9 --- /dev/null +++ b/tests/smart/modules/test_waterleak.py @@ -0,0 +1,57 @@ +from datetime import datetime +from enum import Enum + +import pytest + +from kasa.smart.modules import WaterleakSensor + +from ...conftest import get_device_for_fixture_protocol +from ...device_fixtures import parametrize + +waterleak = parametrize( + "has waterleak", component_filter="sensor_alarm", protocol_filter={"SMART.CHILD"} +) + + +@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"), + [ + ("water_alert", "alert", int), + ("water_alert_timestamp", "alert_timestamp", datetime | None), + ("water_leak", "status", Enum), + ], +) +async def test_waterleak_properties(dev, parent, feature, prop_name, type): + """Test that features are registered and work as expected.""" + dev._parent = parent + waterleak: WaterleakSensor = dev.modules["WaterleakSensor"] + + prop = getattr(waterleak, prop_name) + assert isinstance(prop, type) + + feat = dev.features[feature] + assert feat.value == prop + assert isinstance(feat.value, type) + + +@waterleak +async def test_waterleak_features(dev, parent): + """Test waterleak features.""" + dev._parent = parent + waterleak: WaterleakSensor = dev.modules["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..155c2bdf7 --- /dev/null +++ b/tests/smart/test_smartdevice.py @@ -0,0 +1,1012 @@ +"""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): + 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() + + +@smart_discovery +async def test_device_type_no_update(discovery_mock, caplog: pytest.LogCaptureFixture): + """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): + """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): + """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): + """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}" + ) + + +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, +): + """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, + 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.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() + + if raise_error: + 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(): + """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 + + +@variable_temp_smart +async def test_smart_temp_range(dev: Device): + 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 +): + """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 +): + """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(): + """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): + """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): + """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/__init__.py b/tests/smartcam/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/smartcam/modules/__init__.py b/tests/smartcam/modules/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/smartcam/modules/test_alarm.py b/tests/smartcam/modules/test_alarm.py new file mode 100644 index 000000000..0a176650f --- /dev/null +++ b/tests/smartcam/modules/test_alarm.py @@ -0,0 +1,171 @@ +"""Tests for smart camera devices.""" + +from __future__ import annotations + +import pytest + +from kasa import Device, Module +from kasa.smartcam.modules.alarm import ( + DURATION_MAX, + DURATION_MIN, + VOLUME_MAX, + VOLUME_MIN, +) + +from ...conftest import hub_smartcam + + +@hub_smartcam +async def test_alarm(dev: Device): + """Test device alarm.""" + alarm = dev.modules.get(Module.Alarm) + assert alarm + + original_duration = alarm.alarm_duration + assert original_duration is not None + original_volume = alarm.alarm_volume + assert original_volume is not None + original_sound = alarm.alarm_sound + + try: + # test volume + new_volume = original_volume - 1 if original_volume > 1 else original_volume + 1 + await alarm.set_alarm_volume(new_volume) # type: ignore[arg-type] + await dev.update() + assert alarm.alarm_volume == new_volume + + # test duration + new_duration = ( + original_duration - 1 if original_duration > 1 else original_duration + 1 + ) + await alarm.set_alarm_duration(new_duration) + await dev.update() + assert alarm.alarm_duration == new_duration + + # test start + await alarm.play() + await dev.update() + assert alarm.active + + # test stop + await alarm.stop() + await dev.update() + assert not alarm.active + + # test set sound + new_sound = ( + alarm.alarm_sounds[0] + if alarm.alarm_sound != alarm.alarm_sounds[0] + else alarm.alarm_sounds[1] + ) + await alarm.set_alarm_sound(new_sound) + 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) + await alarm.set_alarm_sound(original_sound) + await dev.update() + + +@hub_smartcam +async def test_alarm_invalid_setters(dev: Device): + """Test device alarm invalid setter values.""" + alarm = dev.modules.get(Module.Alarm) + assert alarm + + # test set sound invalid + msg = f"sound must be one of {', '.join(alarm.alarm_sounds)}: foobar" + with pytest.raises(ValueError, match=msg): + await alarm.set_alarm_sound("foobar") + + # test volume invalid + msg = f"volume must be between {VOLUME_MIN} and {VOLUME_MAX}" + with pytest.raises(ValueError, match=msg): + await alarm.set_alarm_volume(-3) + + # test duration invalid + msg = f"duration must be between {DURATION_MIN} and {DURATION_MAX}" + with pytest.raises(ValueError, match=msg): + await alarm.set_alarm_duration(-3) + + +@hub_smartcam +async def test_alarm_features(dev: Device): + """Test device alarm features.""" + alarm = dev.modules.get(Module.Alarm) + assert alarm + + original_duration = alarm.alarm_duration + assert original_duration is not None + original_volume = alarm.alarm_volume + assert original_volume is not None + original_sound = alarm.alarm_sound + + try: + # test volume + new_volume = original_volume - 1 if original_volume > 1 else original_volume + 1 + feature = dev.features.get("alarm_volume") + assert feature + await feature.set_value(new_volume) # type: ignore[arg-type] + await dev.update() + assert feature.value == new_volume + + # test duration + feature = dev.features.get("alarm_duration") + assert feature + new_duration = ( + original_duration - 1 if original_duration > 1 else original_duration + 1 + ) + await feature.set_value(new_duration) + await dev.update() + assert feature.value == new_duration + + # test start + feature = dev.features.get("test_alarm") + assert feature + await feature.set_value(None) + await dev.update() + feature = dev.features.get("alarm") + assert feature + assert feature.value is True + + # test stop + feature = dev.features.get("stop_alarm") + assert feature + await feature.set_value(None) + await dev.update() + assert dev.features["alarm"].value is False + + # test set sound + feature = dev.features.get("alarm_sound") + assert feature + new_sound = ( + alarm.alarm_sounds[0] + if alarm.alarm_sound != alarm.alarm_sounds[0] + else alarm.alarm_sounds[1] + ) + await feature.set_value(new_sound) + await alarm.set_alarm_sound(new_sound) + await dev.update() + assert feature.value == new_sound + + finally: + await alarm.set_alarm_volume(original_volume) + await alarm.set_alarm_duration(original_duration) + await alarm.set_alarm_sound(original_sound) + await dev.update() diff --git a/tests/smartcam/modules/test_battery.py b/tests/smartcam/modules/test_battery.py new file mode 100644 index 000000000..723cb22b3 --- /dev/null +++ b/tests/smartcam/modules/test_battery.py @@ -0,0 +1,90 @@ +"""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): + """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, expected): + 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, expected_v): + 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, expected): + 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/modules/test_camera.py b/tests/smartcam/modules/test_camera.py new file mode 100644 index 000000000..d668f9f46 --- /dev/null +++ b/tests/smartcam/modules/test_camera.py @@ -0,0 +1,100 @@ +"""Tests for smart camera devices.""" + +from __future__ import annotations + +import base64 +import json +from unittest.mock import patch + +import pytest + +from kasa import Credentials, Device, DeviceType, Module, StreamResolution + +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): + if dev.device_type is DeviceType.Hub: + pytest.skip("Hubs cannot be switched on and off") + + state = dev.is_on + await dev.set_state(not state) + await dev.update() + assert dev.is_on is not state + + +@not_child_camera_smartcam +async def test_stream_rtsp_url(dev: Device): + camera_module = dev.modules.get(Module.Camera) + assert camera_module + + await camera_module.set_state(True) + await dev.update() + assert camera_module.is_on + 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" + + with patch.object(dev.config, "credentials", Credentials("bar", "")): + url = camera_module.stream_rtsp_url() + assert url is None + + with patch.object(dev.config, "credentials", Credentials("", "Foo")): + url = camera_module.stream_rtsp_url() + assert url is None + + # Test with credentials_hash + cred = json.dumps({"un": "bar", "pwd": "foobar"}) + cred_hash = base64.b64encode(cred.encode()).decode() + with ( + patch.object(dev.config, "credentials", None), + patch.object(dev.config, "credentials_hash", cred_hash), + ): + url = camera_module.stream_rtsp_url() + assert url == "rtsp://bar:foobar@127.0.0.123:554/stream1" + + # Test with invalid credentials_hash + with ( + patch.object(dev.config, "credentials", None), + patch.object(dev.config, "credentials_hash", b"238472871"), + ): + url = camera_module.stream_rtsp_url() + assert url is None + + # Test with no credentials + with ( + patch.object(dev.config, "credentials", None), + patch.object(dev.config, "credentials_hash", None), + ): + url = camera_module.stream_rtsp_url() + assert url is None + + +@not_child_camera_smartcam +async def test_onvif_url(dev: Device): + """Test the onvif url.""" + camera_module = dev.modules.get(Module.Camera) + assert camera_module + + 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..090ea0338 --- /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): + """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 +): + """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 +): + """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..c4659f7b1 --- /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=None, + fixture_name="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 +): + 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..fb58dc66a --- /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): + """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): + """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): + """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): + """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): + """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): + """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): + """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): + """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): + """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): + """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..58ab2fd98 --- /dev/null +++ b/tests/smartcam/test_smartcamdevice.py @@ -0,0 +1,197 @@ +"""Tests for smart camera devices.""" + +from __future__ import annotations + +import base64 +from datetime import UTC, datetime +from unittest.mock import AsyncMock, PropertyMock, patch + +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 ..conftest import device_smartcam, hub_smartcam + + +@device_smartcam +async def test_state(dev: Device): + 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): + 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): + 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): + 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): + 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): + """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 + + +@device_smartcam +async def test_wifi_join_typeerror_on_non_rsa_key(dev: SmartCamDevice): + 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 new file mode 100644 index 000000000..14a2ca35d --- /dev/null +++ b/tests/test_bulb.py @@ -0,0 +1,250 @@ +from __future__ import annotations + +import re +from collections.abc import Callable +from contextlib import nullcontext + +import pytest + +from kasa import Device, DeviceType, KasaException, Module +from tests.conftest import handle_turn_on, turn_on +from tests.device_fixtures import ( + bulb, + color_bulb, + non_color_bulb, + non_variable_temp, + variable_temp, +) + + +@bulb +async def test_state_attributes(dev: Device): + assert "Cloud connection" in dev.state_information + assert isinstance(dev.state_information["Cloud connection"], bool) + + +@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.has_feature("hsv") + + hue, saturation, brightness = light.hsv + assert 0 <= hue <= 360 + assert 0 <= saturation <= 100 + assert 0 <= brightness <= 100 + + await light.set_hsv(hue=1, saturation=1, value=1) + + await dev.update() + hue, saturation, brightness = light.hsv + assert hue == 1 + assert saturation == 1 + assert brightness == 1 + + +@color_bulb +@turn_on +@pytest.mark.parametrize( + ("hue", "sat", "brightness", "exception_cls", "error"), + [ + pytest.param(-1, 0, 0, ValueError, "Invalid hue", id="hue out of range"), + pytest.param(361, 0, 0, ValueError, "Invalid hue", id="hue out of range"), + pytest.param( + 0.5, 0, 0, TypeError, "Hue must be an integer", id="hue invalid type" + ), + pytest.param( + "foo", 0, 0, TypeError, "Hue must be an integer", id="hue invalid type" + ), + pytest.param( + 0, -1, 0, ValueError, "Invalid saturation", id="saturation out of range" + ), + pytest.param( + 0, 101, 0, ValueError, "Invalid saturation", id="saturation out of range" + ), + pytest.param( + 0, + 0.5, + 0, + TypeError, + "Saturation must be an integer", + id="saturation invalid type", + ), + pytest.param( + 0, + "foo", + 0, + TypeError, + "Saturation must be an integer", + id="saturation invalid type", + ), + pytest.param( + 0, 0, -1, ValueError, "Invalid brightness", id="brightness out of range" + ), + pytest.param( + 0, 0, 101, ValueError, "Invalid brightness", id="brightness out of range" + ), + pytest.param( + 0, + 0, + 0.5, + TypeError, + "Brightness must be an integer", + id="brightness invalid type", + ), + pytest.param( + 0, + 0, + "foo", + TypeError, + "Brightness must be an integer", + id="brightness invalid type", + ), + ], +) +async def test_invalid_hsv( + dev: Device, turn_on, hue, sat, brightness, exception_cls, error +): + light = dev.modules.get(Module.Light) + assert light + await handle_turn_on(dev, turn_on) + assert light.has_feature("hsv") + with pytest.raises(exception_cls, match=error): + await light.set_hsv(hue, sat, brightness) + + +@color_bulb +@pytest.mark.skip("requires color feature") +async def test_color_state_information(dev: Device): + light = dev.modules.get(Module.Light) + assert light + assert "HSV" in dev.state_information + assert dev.state_information["HSV"] == light.hsv + + +@non_color_bulb +async def test_hsv_on_non_color(dev: Device): + light = dev.modules.get(Module.Light) + assert light + assert not light.has_feature("hsv") + + with pytest.raises(KasaException): + await light.set_hsv(0, 0, 0) + with pytest.raises(KasaException): + print(light.hsv) + + +@variable_temp +@pytest.mark.skip("requires colortemp module") +async def test_variable_temp_state_information(dev: Device): + light = dev.modules.get(Module.Light) + assert light + assert "Color temperature" in dev.state_information + assert dev.state_information["Color temperature"] == light.color_temp + + +@variable_temp +@turn_on +async def test_try_set_colortemp(dev: Device, turn_on): + light = dev.modules.get(Module.Light) + assert light + await handle_turn_on(dev, turn_on) + await light.set_color_temp(2700) + await dev.update() + assert light.color_temp == 2700 + + +@variable_temp +async def test_out_of_range_temperature(dev: Device): + light = dev.modules.get(Module.Light) + assert light + with pytest.raises( + ValueError, match=r"Temperature should be between \d+ and \d+, was 1000" + ): + await light.set_color_temp(1000) + with pytest.raises( + ValueError, match=r"Temperature should be between \d+ and \d+, was 10000" + ): + await light.set_color_temp(10000) + + +@non_variable_temp +async def test_non_variable_temp(dev: Device): + light = dev.modules.get(Module.Light) + assert light + with pytest.raises(KasaException): + await light.set_color_temp(2700) + + with pytest.raises(KasaException): + print(light.color_temp) + + +@bulb +def test_device_type_bulb(dev: Device): + assert dev.device_type in {DeviceType.Bulb, DeviceType.LightStrip} + + +@pytest.mark.parametrize( + ("attribute", "use_msg", "use_fn"), + [ + pytest.param( + "is_color", + 'use has_feature("hsv") instead', + lambda device, mod: mod.has_feature("hsv"), + id="is_color", + ), + pytest.param( + "is_dimmable", + 'use has_feature("brightness") instead', + lambda device, mod: mod.has_feature("brightness"), + id="is_dimmable", + ), + 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", + ), + 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 + + 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 +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 + + 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 new file mode 100644 index 000000000..8bcc05db4 --- /dev/null +++ b/tests/test_childdevice.py @@ -0,0 +1,155 @@ +import inspect +from datetime import UTC, datetime + +import pytest +from freezegun.api import FrozenDateTimeFactory + +from kasa import Device +from kasa.device_type import DeviceType +from kasa.protocols.smartprotocol import _ChildProtocolWrapper +from kasa.smart.smartchilddevice import SmartChildDevice +from kasa.smart.smartdevice import NON_HUB_PARENT_ONLY_MODULES, SmartDevice + +from .conftest import ( + parametrize, + parametrize_combine, + parametrize_subtract, + strip_iot, + strip_smart, +) + +has_children_smart = parametrize( + "has children", component_filter="control_child", protocol_filter={"SMART"} +) +hub_smart = parametrize( + "smart hub", device_type_filter=[DeviceType.Hub], protocol_filter={"SMART"} +) +non_hub_parent_smart = parametrize_subtract(has_children_smart, hub_smart) + +has_children = parametrize_combine([has_children_smart, strip_iot]) + + +@strip_smart +def test_childdevice_init(dev, dummy_protocol, mocker): + """Test that child devices get initialized and use protocol wrapper.""" + assert len(dev.children) > 0 + + first = dev.children[0] + assert isinstance(first.protocol, _ChildProtocolWrapper) + + assert first._info["category"] == "plug.powerstrip.sub-plug" + assert "position" in first._info + + +@strip_smart +async def test_childdevice_update(dev, dummy_protocol, mocker): + """Test that parent update updates children.""" + child_info = dev.internal_state["get_child_device_list"] + child_list = child_info["child_device_list"] + + assert len(dev.children) == child_info["sum"] + first = dev.children[0] + + await dev.update() + + assert dev._info != first._info + assert child_list[0] == first._info + + +@strip_smart +async def test_childdevice_properties(dev: SmartChildDevice): + """Check that accessing childdevice properties do not raise exceptions.""" + assert len(dev.children) > 0 + + first = dev.children[0] + + # children do not have children + assert not first.children + + def _test_property_getters(): + """Try accessing all properties and return a list of encountered exceptions.""" + exceptions = [] + properties = inspect.getmembers( + first.__class__, lambda o: isinstance(o, property) + ) + for prop in properties: + name, _ = prop + # Skip emeter and time properties + # TODO: needs API cleanup, emeter* should probably be removed in favor + # of access through features/modules, handling of time* needs decision. + if ( + name.startswith("emeter_") + or name.startswith("time") + or name.startswith("fan") + or name.startswith("color") + or name.startswith("brightness") + or name.startswith("valid_temperature_range") + or name.startswith("hsv") + or name.startswith("effect") + ): + continue + try: + _ = getattr(first, name) + except Exception as ex: + exceptions.append(ex) + + return exceptions + + exceptions = list(_test_property_getters()) + if exceptions: + raise ExceptionGroup("Accessing child properties caused exceptions", exceptions) + + +@non_hub_parent_smart +async def test_parent_only_modules(dev, dummy_protocol, mocker): + """Test that parent only modules are not available on children.""" + for child in dev.children: + for module in NON_HUB_PARENT_ONLY_MODULES: + assert module not in [type(module) for module in child.modules.values()] + + +@has_children +async def test_parent_property(dev: Device): + """Test a child device exposes it's parent.""" + if not dev.children: + pytest.skip(f"Device {dev} fixture does not have any children") + + assert dev.parent is None + for child in dev.children: + assert child.parent == dev + + +@has_children_smart +@pytest.mark.requires_dummy +async def test_child_time(dev: Device, freezer: FrozenDateTimeFactory): + """Test a child device gets the time from it's parent module. + + This is excluded from real device testing as the test often fail if the + device time is not in the past. + """ + if not dev.children: + pytest.skip(f"Device {dev} fixture does not have any children") + + fallback_time = datetime.now(UTC).astimezone().replace(microsecond=0) + assert dev.parent is None + for child in dev.children: + assert child.time != fallback_time + + +@pytest.mark.xdist_group(name="caplog") +async def test_child_device_type_unknown(caplog): + """Test for device type when category is unknown.""" + + class DummyDevice(SmartChildDevice): + def __init__(self): + super().__init__( + SmartDevice("127.0.0.1"), + {"device_id": "1", "category": "foobar"}, + { + "component_list": [{"id": "device", "ver_code": 1}], + }, + ) + + assert DummyDevice().device_type is DeviceType.Unknown + msg = "Unknown child device type foobar for model None, please open issue" + assert msg in caplog.text diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 000000000..6cba5d2a5 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,1502 @@ +import json +import re +from datetime import datetime +from unittest.mock import ANY, PropertyMock, patch +from zoneinfo import ZoneInfo + +import asyncclick as click +import pytest +from asyncclick.testing import CliRunner +from pytest_mock import MockerFixture + +from kasa import ( + AuthenticationError, + ColorTempRange, + Credentials, + Device, + DeviceError, + DeviceType, + EmeterStatus, + KasaException, + Module, +) +from kasa.cli.device import ( + alias, + factory_reset, + led, + reboot, + state, + sysinfo, + toggle, + update_credentials, +) +from kasa.cli.light import ( + brightness, + effect, + hsv, + presets, + presets_modify, + temperature, +) +from kasa.cli.main import TYPES, _legacy_type_to_class, cli, cmd_command, raw_command +from kasa.cli.time import time +from kasa.cli.usage import energy +from kasa.cli.wifi import wifi +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, +) + +# The cli tests should be testing the cli logic rather than a physical device +# so mark the whole file for skipping with real devices. +pytestmark = [pytest.mark.requires_dummy] + + +async def test_help(runner): + """Test that all the lazy modules are correctly names.""" + res = await runner.invoke(cli, ["--help"]) + assert res.exit_code == 0, "--help failed, check lazy module names" + + +@pytest.mark.parametrize( + ("device_family", "encrypt_type"), + [ + pytest.param(None, None, id="No connect params"), + pytest.param("SMART.TAPOPLUG", None, id="Only device_family"), + ], +) +async def test_update_called_by_cli(dev, mocker, runner, device_family, encrypt_type): + """Test that device update is called on main.""" + update = mocker.patch.object(dev, "update") + + # These will mock the features to avoid accessing non-existing + mocker.patch("kasa.device.Device.features", return_value={}) + mocker.patch("kasa.iot.iotdevice.IotDevice.features", return_value={}) + + mocker.patch("kasa.discover.Discover.discover_single", return_value=dev) + + res = await runner.invoke( + cli, + [ + "--host", + "127.0.0.1", + "--username", + "foo", + "--password", + "bar", + "--device-family", + device_family, + "--encrypt-type", + encrypt_type, + ], + catch_exceptions=False, + ) + assert res.exit_code == 0 + update.assert_called() + + +async def test_list_devices(discovery_mock, runner): + """Test that device update is called on main.""" + res = await runner.invoke( + cli, + ["--username", "foo", "--password", "bar", "discover", "list"], + catch_exceptions=False, + ) + assert res.exit_code == 0 + 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.protocols.protocol.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_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=exception, + ) + res = await runner.invoke( + cli, + ["--username", "foo", "--password", "bar", "discover", "list"], + catch_exceptions=False, + ) + assert res.exit_code == 0 + 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): + """Test that device update is called on main.""" + res = await runner.invoke( + cli, + ["--username", "foo", "--password", "bar", "discover", "list"], + catch_exceptions=False, + ) + assert res.exit_code == 0 + 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 + + +async def test_sysinfo(dev: Device, runner): + res = await runner.invoke(sysinfo, obj=dev) + assert "System info" in res.output + assert dev.model in res.output + + +@turn_on +async def test_state(dev, turn_on, runner): + await handle_turn_on(dev, turn_on) + await dev.update() + res = await runner.invoke(state, obj=dev) + + if dev.is_on: + assert "Device state: True" in res.output + else: + assert "Device state: False" in res.output + + +@turn_on +async def test_toggle(dev, turn_on, runner): + if isinstance(dev, SmartCamDevice) and dev.device_type == DeviceType.Hub: + pytest.skip(reason="Hub cannot toggle state") + + await handle_turn_on(dev, turn_on) + await dev.update() + assert dev.is_on == turn_on + + await runner.invoke(toggle, obj=dev) + await dev.update() + assert dev.is_on != turn_on + + +async def test_alias(dev, runner): + res = await runner.invoke(alias, obj=dev) + assert f"Alias: {dev.alias}" in res.output + + old_alias = dev.alias + + new_alias = "new alias" + res = await runner.invoke(alias, [new_alias], obj=dev) + assert f"Setting alias to {new_alias}" in res.output + await dev.update() + + res = await runner.invoke(alias, obj=dev) + assert f"Alias: {new_alias}" in res.output + + # 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): + update = mocker.patch.object(dev, "update") + from kasa.smart import SmartDevice + + if isinstance(dev, SmartCamDevice): + params = [ + "na", + "getDeviceInfo", + '{"device_info": {"name": ["basic_info", "info"]}}', + ] + elif isinstance(dev, SmartDevice): + params = ["na", "get_device_info"] + else: + params = ["system", "get_sysinfo"] + res = await runner.invoke(raw_command, params, obj=dev) + + # Make sure that update was not called for wifi + with pytest.raises(AssertionError): + update.assert_called() + + assert res.exit_code == 0 + assert dev.model in res.output + + res = await runner.invoke(raw_command, obj=dev) + assert res.exit_code != 0 + assert "Usage" in res.output + + +async def test_command_with_child(dev, mocker, runner): + """Test 'command' command with --child.""" + update_mock = mocker.patch.object(dev, "update") + + # create_autospec for device slows tests way too much, so we use a dummy here + class DummyDevice(dev.__class__): + def __init__(self): + super().__init__("127.0.0.1") + # device_type and _info initialised for repr + self._device_type = Device.Type.StripSocket + self._info = {} + + async def _query_helper(*_, **__): + return {"dummy": "response"} + + dummy_child = DummyDevice() + + mocker.patch.object(dev, "_children", {"XYZ": [dummy_child]}) + mocker.patch.object(dev, "get_child_device", return_value=dummy_child) + + res = await runner.invoke( + cmd_command, + ["--child", "XYZ", "command", "'params'"], + obj=dev, + catch_exceptions=False, + ) + + update_mock.assert_called() + assert '{"dummy": "response"}' in res.output + assert res.exit_code == 0 + + +@device_smart +async def test_reboot(dev, mocker, runner): + """Test that reboot works on SMART devices.""" + query_mock = mocker.patch.object(dev.protocol, "query") + + res = await runner.invoke( + reboot, + obj=dev, + ) + + query_mock.assert_called() + assert res.exit_code == 0 + + +@device_smart +async def test_factory_reset(dev, mocker, runner): + """Test that factory reset works on SMART devices.""" + query_mock = mocker.patch.object(dev.protocol, "query") + + res = await runner.invoke( + factory_reset, + obj=dev, + input="y\n", + ) + + query_mock.assert_called() + assert res.exit_code == 0 + + +@device_smart +async def test_wifi_scan(dev, runner): + res = await runner.invoke(wifi, ["scan"], obj=dev) + + assert res.exit_code == 0 + assert re.search(r"Found [\d]+ wifi networks!", res.output) + + +@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", "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, + ) + + # 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 + + +@device_smart +async def test_wifi_join_no_creds(dev, runner): + dev.protocol._transport._credentials = None + res = await runner.invoke( + wifi, + ["join", "FOOBAR", "--keytype", "wpa_psk", "--password", "foobar"], + obj=dev, + ) + + assert res.exit_code != 0 + assert isinstance(res.exception, AuthenticationError) + + +@device_smart +async def test_wifi_join_exception(dev, mocker, runner): + mocker.patch.object(dev.protocol, "query", side_effect=DeviceError(error_code=9999)) + res = await runner.invoke( + wifi, + ["join", "FOOBAR", "--keytype", "wpa_psk", "--password", "foobar"], + obj=dev, + ) + + assert res.exit_code != 0 + assert isinstance(res.exception, KasaException) + + +@device_smart +async def test_update_credentials(dev, runner): + res = await runner.invoke( + update_credentials, + ["--username", "foo", "--password", "bar"], + input="y\n", + obj=dev, + ) + + assert res.exit_code == 0 + assert ( + "Do you really want to replace the existing credentials? [y/N]: y\n" + in res.output + ) + + +async def test_time_get(dev, runner): + """Test time get command.""" + res = await runner.invoke( + time, + obj=dev, + ) + assert res.exit_code == 0 + assert "Current time: " in res.output + + +async def test_time_sync(dev, mocker, runner): + """Test time sync command.""" + update = mocker.patch.object(dev, "update") + set_time_mock = mocker.spy(dev.modules[Module.Time], "set_time") + res = await runner.invoke( + time, + ["sync"], + obj=dev, + ) + set_time_mock.assert_called() + update.assert_called() + + assert res.exit_code == 0 + assert "Old time: " in res.output + assert "New time: " in res.output + + +async def test_time_set(dev: Device, mocker, runner): + """Test time set command.""" + time_mod = dev.modules[Module.Time] + set_time_mock = mocker.spy(time_mod, "set_time") + dt = datetime(2024, 10, 15, 8, 15) + res = await runner.invoke( + time, + ["set", str(dt.year), str(dt.month), str(dt.day), str(dt.hour), str(dt.minute)], + obj=dev, + ) + set_time_mock.assert_called() + assert time_mod.time == dt.replace(tzinfo=time_mod.timezone) + + assert res.exit_code == 0 + assert "Old time: " in res.output + assert "New time: " in res.output + + zone = ZoneInfo("Europe/Berlin") + dt = dt.replace(tzinfo=zone) + res = await runner.invoke( + time, + [ + "set", + str(dt.year), + str(dt.month), + str(dt.day), + str(dt.hour), + str(dt.minute), + "--timezone", + zone.key, + ], + input="y\n", + obj=dev, + ) + + assert time_mod.time == dt + + assert res.exit_code == 0 + assert "Old time: " in res.output + assert "New time: " in res.output + + +async def test_emeter(dev: Device, mocker, runner): + mocker.patch("kasa.Discover.discover_single", return_value=dev) + base_cmd = ["--host", "dummy", "energy"] + res = await runner.invoke(cli, base_cmd, obj=dev) + if not (energy := dev.modules.get(Module.Energy)): + assert "Device has no energy module." in res.output + return + + assert "== Energy ==" in res.output + + if dev.device_type is not DeviceType.Strip: + res = await runner.invoke(cli, [*base_cmd, "--index", "0"], obj=dev) + assert f"Device: {dev.host} does not have children" in res.output + res = await runner.invoke(cli, [*base_cmd, "--name", "mock"], obj=dev) + assert f"Device: {dev.host} does not have children" in res.output + + if dev.device_type is DeviceType.Strip and len(dev.children) > 0: + child_energy = dev.children[0].modules.get(Module.Energy) + assert child_energy + + with patch.object( + type(child_energy), "status", new_callable=PropertyMock + ) as child_status: + child_status.return_value = EmeterStatus({"voltage_mv": 122066}) + + res = await runner.invoke(cli, [*base_cmd, "--index", "0"], obj=dev) + assert "Voltage: 122.066 V" in res.output + child_status.assert_called() + assert child_status.call_count == 1 + + res = await runner.invoke( + cli, [*base_cmd, "--name", dev.children[0].alias], obj=dev + ) + assert "Voltage: 122.066 V" in res.output + assert child_status.call_count == 2 + + if isinstance(dev, IotDevice): + monthly = mocker.patch.object(energy, "get_monthly_stats") + monthly.return_value = {1: 1234} + res = await runner.invoke(cli, [*base_cmd, "--year", "1900"], obj=dev) + if not isinstance(dev, IotDevice): + assert "Device does not support historical statistics" in res.output + return + assert "For year" in res.output + assert "1, 1234" in res.output + monthly.assert_called_with(year=1900) + + if isinstance(dev, IotDevice): + daily = mocker.patch.object(energy, "get_daily_stats") + daily.return_value = {1: 1234} + res = await runner.invoke(cli, [*base_cmd, "--month", "1900-12"], obj=dev) + if not isinstance(dev, IotDevice): + assert "Device has no historical statistics" in res.output + return + assert "For month" in res.output + assert "1, 1234" in res.output + daily.assert_called_with(year=1900, month=12) + + +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.has_feature( + "brightness" + ): + assert "This device does not support brightness." in res.output + return + + res = await runner.invoke(brightness, obj=dev) + assert f"Brightness: {light.brightness}" in res.output + + res = await runner.invoke(brightness, ["12"], obj=dev) + assert "Setting brightness" in res.output + await dev.update() + + res = await runner.invoke(brightness, obj=dev) + assert "Brightness: 12" in res.output + + +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 ( + 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 = 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) + res = await runner.invoke(temperature, [str(val)], obj=dev) + assert "Setting color temperature to " in res.output + await dev.update() + + res = await runner.invoke(temperature, obj=dev) + assert f"Color temperature: {val}" in res.output + assert res.exit_code == 0 + + invalid_max = valid_range.max + 100 + # Lights that support the maximum range will not get past the click cli range check + # So can't be tested for the internal range check. + if invalid_max < 9000: + res = await runner.invoke(temperature, [str(invalid_max)], obj=dev) + assert res.exit_code == 1 + assert isinstance(res.exception, ValueError) + + res = await runner.invoke(temperature, [str(9100)], obj=dev) + assert res.exit_code == 2 + + +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.has_feature("hsv"): + assert "Device does not support colors" in res.output + return + + res = await runner.invoke(hsv, obj=dev) + assert f"Current HSV: {light.hsv}" in res.output + + res = await runner.invoke(hsv, ["180", "50", "50"], obj=dev) + assert "Setting HSV: 180 50 50" in res.output + assert res.exit_code == 0 + await dev.update() + + res = await runner.invoke(hsv, ["180", "50"], obj=dev) + assert "Setting a color requires 3 values." in res.output + assert res.exit_code == 2 + + +async def test_light_effect(dev: Device, runner: CliRunner): + res = await runner.invoke(effect, obj=dev) + if not (light_effect := dev.modules.get(Module.LightEffect)): + assert "Device does not support effects" in res.output + return + + # Start off with a known state of off + await light_effect.set_effect(light_effect.LIGHT_EFFECTS_OFF) + await dev.update() + assert light_effect.effect == light_effect.LIGHT_EFFECTS_OFF + + res = await runner.invoke(effect, obj=dev) + assert f"Light effect: {light_effect.effect}" in res.output + assert res.exit_code == 0 + + res = await runner.invoke(effect, [light_effect.effect_list[1]], obj=dev) + assert f"Setting Effect: {light_effect.effect_list[1]}" in res.output + assert res.exit_code == 0 + await dev.update() + assert light_effect.effect == light_effect.effect_list[1] + + res = await runner.invoke(effect, ["foobar"], obj=dev) + assert f"Effect must be one of: {light_effect.effect_list}" in res.output + assert res.exit_code == 2 + + +async def test_light_preset(dev: Device, runner: CliRunner): + res = await runner.invoke(presets, obj=dev) + if not (light_preset := dev.modules.get(Module.LightPreset)): + assert "Device does not support light presets" in res.output + return + + if len(light_preset.preset_states_list) == 0: + pytest.skip( + "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] + await light_preset.set_preset(first_name) + await dev.update() + assert light_preset.preset == first_name + + res = await runner.invoke(presets, obj=dev) + assert "Brightness" in res.output + assert res.exit_code == 0 + + res = await runner.invoke( + presets_modify, + [ + "0", + "--brightness", + "12", + ], + obj=dev, + ) + await dev.update() + assert light_preset.preset_states_list[0].brightness == 12 + + res = await runner.invoke( + presets_modify, + [ + "0", + ], + obj=dev, + ) + await dev.update() + assert "Need to supply at least one option to modify." in res.output + + +async def test_led(dev: Device, runner: CliRunner): + res = await runner.invoke(led, obj=dev) + if not (led_module := dev.modules.get(Module.Led)): + assert "Device does not support led" in res.output + return + + res = await runner.invoke(led, obj=dev) + assert f"LED state: {led_module.led}" in res.output + assert res.exit_code == 0 + + res = await runner.invoke(led, ["on"], obj=dev) + assert "Turning led to True" in res.output + assert res.exit_code == 0 + await dev.update() + assert led_module.led is True + + res = await runner.invoke(led, ["off"], obj=dev) + assert "Turning led to False" in res.output + assert res.exit_code == 0 + await dev.update() + assert led_module.led is False + + +async def test_json_output(dev: Device, mocker, runner): + """Test that the json output produces correct output.""" + mocker.patch("kasa.Discover.discover_single", return_value=dev) + # These will mock the features to avoid accessing non-existing ones + mocker.patch("kasa.device.Device.features", return_value={}) + mocker.patch("kasa.iot.iotdevice.IotDevice.features", return_value={}) + + res = await runner.invoke(cli, ["--host", "127.0.0.1", "--json", "state"], obj=dev) + assert res.exit_code == 0 + assert json.loads(res.output) == dev.internal_state + + +@new_discovery +async def test_credentials(discovery_mock, mocker, runner): + """Test credentials are passed correctly from cli to device.""" + # Patch state to echo username and password + pass_dev = click.make_pass_decorator(Device) + + @pass_dev + async def _state(dev: Device): + if dev.credentials: + click.echo( + f"Username:{dev.credentials.username} Password:{dev.credentials.password}" + ) + + mocker.patch("kasa.cli.device.state", new=_state) + + dr = DiscoveryResult.from_dict(discovery_mock.discovery_data["result"]) + res = await runner.invoke( + cli, + [ + "--host", + "127.0.0.123", + "--username", + "foo", + "--password", + "bar", + "--device-family", + 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 + + assert "Username:foo Password:bar\n" in res.output + + +async def test_without_device_type(dev, mocker, runner): + """Test connecting without the device type.""" + discovery_mock = mocker.patch( + "kasa.discover.Discover.discover_single", return_value=dev + ) + # These will mock the features to avoid accessing non-existing + mocker.patch("kasa.device.Device.features", return_value={}) + mocker.patch("kasa.iot.iotdevice.IotDevice.features", return_value={}) + + res = await runner.invoke( + cli, + [ + "--host", + "127.0.0.1", + "--username", + "foo", + "--password", + "bar", + "--discovery-timeout", + "7", + ], + ) + assert res.exit_code == 0 + discovery_mock.assert_called_once_with( + "127.0.0.1", + port=None, + credentials=Credentials("foo", "bar"), + timeout=5, + discovery_timeout=7, + on_unsupported=ANY, + on_discovered_raw=ANY, + ) + + +@pytest.mark.parametrize("auth_param", ["--username", "--password"]) +async def test_invalid_credential_params(auth_param, runner): + """Test for handling only one of username or password supplied.""" + res = await runner.invoke( + cli, + [ + "--host", + "127.0.0.1", + "--type", + "plug", + auth_param, + "foo", + ], + ) + assert res.exit_code == 2 + assert ( + "Error: Using authentication requires both --username and --password" + in res.output + ) + + +async def test_duplicate_target_device(runner): + """Test that defining both --host or --alias gives an error.""" + res = await runner.invoke( + cli, + [ + "--host", + "127.0.0.1", + "--alias", + "foo", + ], + ) + assert res.exit_code == 2 + assert "Error: Use either --alias or --host, not both." in res.output + + +async def test_discover(discovery_mock, mocker, runner): + """Test discovery output.""" + # These will mock the features to avoid accessing non-existing + mocker.patch("kasa.device.Device.features", return_value={}) + mocker.patch("kasa.iot.iotdevice.IotDevice.features", return_value={}) + + res = await runner.invoke( + cli, + [ + "--discovery-timeout", + 0, + "--username", + "foo", + "--password", + "bar", + "--verbose", + "discover", + ], + ) + assert res.exit_code == 0 + + +async def test_discover_host(discovery_mock, mocker, runner): + """Test discovery output.""" + # These will mock the features to avoid accessing non-existing + mocker.patch("kasa.device.Device.features", return_value={}) + mocker.patch("kasa.iot.iotdevice.IotDevice.features", return_value={}) + + res = await runner.invoke( + cli, + [ + "--discovery-timeout", + 0, + "--host", + "127.0.0.123", + "--username", + "foo", + "--password", + "bar", + "--verbose", + ], + ) + assert res.exit_code == 0 + + +async def test_discover_unsupported(unsupported_device_info, runner): + """Test discovery output.""" + res = await runner.invoke( + cli, + [ + "--discovery-timeout", + 0, + "--username", + "foo", + "--password", + "bar", + "--verbose", + "discover", + ], + ) + assert res.exit_code == 0 + assert "== Unsupported device ==" in res.output + + +async def test_host_unsupported(unsupported_device_info, runner): + """Test discovery output.""" + host = "127.0.0.1" + + res = await runner.invoke( + cli, + [ + "--host", + host, + "--username", + "foo", + "--password", + "bar", + "--debug", + ], + ) + + assert res.exit_code != 0 + assert "== Unsupported device ==" in res.output + + +@new_discovery +async def test_discover_auth_failed(discovery_mock, mocker, runner): + """Test discovery output.""" + host = "127.0.0.1" + discovery_mock.ip = host + device_class = Discover._get_device_class(discovery_mock.discovery_data) + mocker.patch.object( + device_class, + "update", + side_effect=AuthenticationError("Failed to authenticate"), + ) + res = await runner.invoke( + cli, + [ + "--discovery-timeout", + 0, + "--username", + "foo", + "--password", + "bar", + "--verbose", + "discover", + ], + ) + + assert res.exit_code == 0 + assert "== Authentication failed for device ==" in res.output + assert "== Discovery Result ==" in res.output + + +@new_discovery +async def test_host_auth_failed(discovery_mock, mocker, runner): + """Test discovery output.""" + host = "127.0.0.1" + discovery_mock.ip = host + device_class = Discover._get_device_class(discovery_mock.discovery_data) + mocker.patch.object( + device_class, + "update", + side_effect=AuthenticationError("Failed to authenticate"), + ) + res = await runner.invoke( + cli, + [ + "--host", + host, + "--username", + "foo", + "--password", + "bar", + "--debug", + ], + ) + + assert res.exit_code != 0 + assert isinstance(res.exception, AuthenticationError) + + +@pytest.mark.parametrize("device_type", TYPES) +async def test_type_param(device_type, mocker, runner): + """Test for handling only one of username or password supplied.""" + result_device = FileNotFoundError + pass_dev = click.make_pass_decorator(Device) + + @pass_dev + async def _state(dev: Device): + nonlocal result_device + result_device = dev + + mocker.patch("kasa.cli.device.state", new=_state) + if device_type == "camera": + expected_type = SmartCamDevice + elif device_type == "smart": + expected_type = SmartDevice + else: + expected_type = _legacy_type_to_class(device_type) + mocker.patch.object(expected_type, "update") + res = await runner.invoke( + cli, + ["--type", device_type, "--host", "127.0.0.1"], + ) + assert res.exit_code == 0 + 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" +) +async def test_shell(dev: Device, mocker, runner): + """Test that the shell commands tries to embed a shell.""" + mocker.patch("kasa.Discover.discover", return_value=[dev]) + # repl = mocker.patch("ptpython.repl") + mocker.patch.dict( + "sys.modules", + {"ptpython": mocker.MagicMock(), "ptpython.repl": mocker.MagicMock()}, + ) + embed = mocker.patch("ptpython.repl.embed") + res = await runner.invoke(cli, ["shell"], obj=dev) + assert res.exit_code == 0 + embed.assert_called() + + +async def test_errors(mocker, runner): + err = KasaException("Foobar") + + # Test masking + mocker.patch("kasa.Discover.discover", side_effect=err) + res = await runner.invoke( + cli, + ["--username", "foo", "--password", "bar"], + ) + assert res.exit_code == 1 + assert "Raised error: Foobar" in res.output + assert "Run with --debug enabled to see stacktrace" in res.output + assert isinstance(res.exception, SystemExit) + + # Test --debug + res = await runner.invoke( + cli, + ["--debug"], + ) + assert res.exit_code == 1 + assert "Raised error: Foobar" in res.output + assert res.exception == err + + # Test no device passed to subcommand + mocker.patch("kasa.Discover.discover", return_value={}) + res = await runner.invoke( + cli, + ["sysinfo"], + ) + assert res.exit_code == 1 + assert ( + "Only discover is available without --host or --alias" + in res.output.replace("\n", "") # Remove newlines from rich formatting + ) + assert isinstance(res.exception, SystemExit) + + # Test click error + res = await runner.invoke( + cli, + ["--foobar"], + ) + assert res.exit_code == 2 + assert "Raised error:" not in res.output + + +async def test_feature(mocker, runner): + """Test feature command.""" + dummy_device = await get_device_for_fixture_protocol( + "P300(EU)_1.0_1.0.13.json", "SMART" + ) + mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device) + res = await runner.invoke( + cli, + ["--host", "127.0.0.123", "--debug", "feature"], + catch_exceptions=False, + ) + assert "LED" in res.output + assert "== Child " in res.output # child listing + + assert res.exit_code == 0 + + +async def test_features_all(discovery_mock, mocker, runner): + """Test feature command on all fixtures.""" + res = await runner.invoke( + cli, + ["--host", "127.0.0.123", "--debug", "feature"], + catch_exceptions=False, + ) + assert "== Primary features ==" in res.output + assert "== Information ==" in res.output + assert "== Configuration ==" in res.output + assert "== Debug ==" in res.output + assert res.exit_code == 0 + + +async def test_feature_single(mocker, runner): + """Test feature command returning single value.""" + dummy_device = await get_device_for_fixture_protocol( + "P300(EU)_1.0_1.0.13.json", "SMART" + ) + mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device) + res = await runner.invoke( + cli, + ["--host", "127.0.0.123", "--debug", "feature", "led"], + catch_exceptions=False, + ) + assert "LED" in res.output + assert "== Features ==" not in res.output + assert res.exit_code == 0 + + +async def test_feature_missing(mocker, runner): + """Test feature command returning single value.""" + dummy_device = await get_device_for_fixture_protocol( + "P300(EU)_1.0_1.0.13.json", "SMART" + ) + mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device) + res = await runner.invoke( + cli, + ["--host", "127.0.0.123", "--debug", "feature", "missing"], + catch_exceptions=False, + ) + assert "No feature by name 'missing'" in res.output + assert "== Features ==" not in res.output + assert res.exit_code == 1 + + +async def test_feature_set(mocker, runner): + """Test feature command's set value.""" + dummy_device = await get_device_for_fixture_protocol( + "P300(EU)_1.0_1.0.13.json", "SMART" + ) + led_setter = mocker.patch("kasa.smart.modules.led.Led.set_led") + mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device) + + res = await runner.invoke( + cli, + ["--host", "127.0.0.123", "--debug", "feature", "led", "True"], + catch_exceptions=False, + ) + + led_setter.assert_called_with(True) + assert "Changing led from True to True" in res.output + assert res.exit_code == 0 + + +async def test_feature_set_child(mocker, runner): + """Test feature command's set value.""" + dummy_device = await get_device_for_fixture_protocol( + "P300(EU)_1.0_1.0.13.json", "SMART" + ) + setter = mocker.patch("kasa.smart.smartdevice.SmartDevice.set_state") + + mocker.patch("kasa.discover.Discover.discover_single", return_value=dummy_device) + get_child_device = mocker.spy(dummy_device, "get_child_device") + + child_id = "SCRUBBED_CHILD_DEVICE_ID_1" + + res = await runner.invoke( + cli, + [ + "--host", + "127.0.0.123", + "--debug", + "feature", + "--child", + child_id, + "state", + "True", + ], + catch_exceptions=False, + ) + + get_child_device.assert_called() + setter.assert_called_with(True) + + assert f"Targeting child device {child_id}" + assert "Changing state from False to True" in res.output + 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 +): + if not dev.children: + res = await runner.invoke(alias, ["--child-index", "0"], obj=dev) + assert f"Device: {dev.host} does not have children" in res.output + assert res.exit_code == 1 + + res = await runner.invoke(alias, ["--index", "0"], obj=dev) + assert f"Device: {dev.host} does not have children" in res.output + assert res.exit_code == 1 + + res = await runner.invoke(alias, ["--child", "Plug 2"], obj=dev) + assert f"Device: {dev.host} does not have children" in res.output + assert res.exit_code == 1 + + res = await runner.invoke(alias, ["--name", "Plug 2"], obj=dev) + assert f"Device: {dev.host} does not have children" in res.output + assert res.exit_code == 1 + + if dev.children: + child_alias = dev.children[0].alias + assert child_alias + child_device_id = dev.children[0].device_id + child_count = len(dev.children) + child_update_method = dev.children[0].update + + # Test child retrieval + res = await runner.invoke(alias, ["--child-index", "0"], obj=dev) + assert f"Targeting child device {child_alias}" in res.output + assert res.exit_code == 0 + + res = await runner.invoke(alias, ["--index", "0"], obj=dev) + assert f"Targeting child device {child_alias}" in res.output + assert res.exit_code == 0 + + res = await runner.invoke(alias, ["--child", child_alias], obj=dev) + assert f"Targeting child device {child_alias}" in res.output + assert res.exit_code == 0 + + res = await runner.invoke(alias, ["--name", child_alias], obj=dev) + assert f"Targeting child device {child_alias}" in res.output + assert res.exit_code == 0 + + res = await runner.invoke(alias, ["--child", child_device_id], obj=dev) + assert f"Targeting child device {child_alias}" in res.output + assert res.exit_code == 0 + + res = await runner.invoke(alias, ["--name", child_device_id], obj=dev) + assert f"Targeting child device {child_alias}" in res.output + assert res.exit_code == 0 + + # Test invalid name and index + res = await runner.invoke(alias, ["--child-index", "-1"], obj=dev) + assert f"Invalid index -1, device has {child_count} children" in res.output + assert res.exit_code == 1 + + res = await runner.invoke(alias, ["--child-index", str(child_count)], obj=dev) + assert ( + f"Invalid index {child_count}, device has {child_count} children" + in res.output + ) + assert res.exit_code == 1 + + res = await runner.invoke(alias, ["--child", "foobar"], obj=dev) + assert "No child device found with device_id or name: foobar" in res.output + assert res.exit_code == 1 + + # Test using both options: + + res = await runner.invoke( + alias, ["--child", child_alias, "--child-index", "0"], obj=dev + ) + assert "Use either --child or --child-index, not both." in res.output + assert res.exit_code == 2 + + # Test child with no parameter interactive prompt + + res = await runner.invoke(alias, ["--child"], obj=dev, input="0\n") + assert "Enter the index number of the child device:" in res.output + assert f"Alias: {child_alias}" in res.output + assert res.exit_code == 0 + + # Test values and updates + + res = await runner.invoke(alias, ["foo", "--child", child_device_id], obj=dev) + assert "Alias set to: foo" in res.output + assert res.exit_code == 0 + + # Test help has command options plus child options + + res = await runner.invoke(energy, ["--help"], obj=dev) + assert "--year" in res.output + assert "--child" in res.output + assert "--child-index" in res.output + assert res.exit_code == 0 + + # Test child update patching calls parent and is undone on exit + + parent_update_spy = mocker.spy(dev, "update") + res = await runner.invoke(alias, ["bar", "--child", child_device_id], obj=dev) + assert "Alias set to: bar" in res.output + assert res.exit_code == 0 + parent_update_spy.assert_called_once() + assert dev.children[0].update == child_update_method + + +async def test_discover_config(dev: Device, mocker, runner): + """Test that device config is returned.""" + host = "127.0.0.1" + mocker.patch("kasa.device_factory._connect", side_effect=[Exception, dev]) + + res = await runner.invoke( + cli, + [ + "--username", + "foo", + "--password", + "bar", + "--host", + host, + "discover", + "config", + ], + catch_exceptions=False, + ) + assert res.exit_code == 0 + cparam = dev.config.connection_type + 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+ \+ \w+ failed", + res.output.replace("\n", ""), + ) + assert re.search( + r"Attempt to connect to 127\.0\.0\.1 with \w+ \+ \w+ \+ \w+ \+ \w+ succeeded", + res.output.replace("\n", ""), + ) + + +async def test_discover_config_invalid(mocker, runner): + """Test the device config command with invalids.""" + host = "127.0.0.1" + mocker.patch("kasa.discover.Discover.try_connect_all", return_value=None) + + res = await runner.invoke( + cli, + [ + "--username", + "foo", + "--password", + "bar", + "--host", + host, + "discover", + "config", + ], + catch_exceptions=False, + ) + assert res.exit_code == 1 + assert f"Unable to connect to {host}" in res.output + + res = await runner.invoke( + cli, + ["--username", "foo", "--password", "bar", "discover", "config"], + catch_exceptions=False, + ) + assert res.exit_code == 1 + assert "--host option must be supplied to discover config" in res.output + + res = await runner.invoke( + cli, + [ + "--username", + "foo", + "--password", + "bar", + "--host", + host, + "--target", + "127.0.0.2", + "discover", + "config", + ], + catch_exceptions=False, + ) + assert res.exit_code == 1 + assert "--target is not a valid option for single host discovery" in res.output diff --git a/tests/test_common_modules.py b/tests/test_common_modules.py new file mode 100644 index 000000000..f6b35a81d --- /dev/null +++ b/tests/test_common_modules.py @@ -0,0 +1,670 @@ +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 + +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, + dimmable_iot, + dimmer_iot, + get_parent_and_child_modules, + lightstrip_iot, + parametrize, + parametrize_combine, + plug_iot, + variable_temp_iot, +) + +led_smart = parametrize( + "has led smart", component_filter="led", protocol_filter={"SMART"} +) +led = parametrize_combine([led_smart, plug_iot]) + +light_effect_smart = parametrize( + "has light effect smart", component_filter="light_effect", protocol_filter={"SMART"} +) +light_strip_effect_smart = parametrize( + "has light strip effect smart", + component_filter="light_strip_lighting_effect", + protocol_filter={"SMART"}, +) +light_effect = parametrize_combine( + [light_effect_smart, light_strip_effect_smart, lightstrip_iot] +) + +dimmable_smart = parametrize( + "dimmable smart", component_filter="brightness", protocol_filter={"SMART"} +) +dimmable = parametrize_combine([dimmable_smart, dimmer_iot, dimmable_iot]) + +variable_temp_smart = parametrize( + "variable temp smart", + component_filter="color_temperature", + protocol_filter={"SMART"}, +) + +variable_temp = parametrize_combine([variable_temp_iot, variable_temp_smart]) + +light_preset_smart = parametrize( + "has light preset smart", component_filter="preset", protocol_filter={"SMART"} +) + +light_preset = parametrize_combine([light_preset_smart, bulb_iot]) + +light = parametrize_combine([bulb_smart, bulb_iot, dimmable]) + +temp_control_smart = parametrize( + "has temp control smart", + component_filter="temp_control", + protocol_filter={"SMART.CHILD"}, +) + + +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.""" + led_module = dev.modules.get(Module.Led) + assert led_module + feat = dev.features["led"] + + call = mocker.spy(led_module, "call") + await led_module.set_led(True) + assert call.call_count == 1 + await dev.update() + assert led_module.led is True + assert feat.value is True + + await led_module.set_led(False) + assert call.call_count == 2 + await dev.update() + assert led_module.led is False + assert feat.value is False + + await feat.set_value(True) + assert call.call_count == 3 + await dev.update() + assert feat.value is True + assert led_module.led is True + + +@light_effect +async def test_light_effect_module(dev: Device, mocker: MockerFixture): + """Test fan speed feature.""" + light_effect_module = dev.modules[Module.LightEffect] + assert light_effect_module + feat = dev.features["light_effect"] + + call = mocker.spy(dev, "_query_helper") + effect_list = light_effect_module.effect_list + assert "Off" in effect_list + assert effect_list.index("Off") == 0 + assert len(effect_list) > 1 + assert effect_list == feat.choices + + assert light_effect_module.has_custom_effects is not None + + await light_effect_module.set_effect("Off") + call.assert_called() + await dev.update() + assert light_effect_module.effect == "Off" + assert feat.value == "Off" + call.reset_mock() + + second_effect = effect_list[1] + await light_effect_module.set_effect(second_effect) + call.assert_called() + await dev.update() + assert light_effect_module.effect == second_effect + assert feat.value == second_effect + call.reset_mock() + + last_effect = effect_list[len(effect_list) - 1] + await light_effect_module.set_effect(last_effect) + call.assert_called() + await dev.update() + assert light_effect_module.effect == last_effect + assert feat.value == last_effect + call.reset_mock() + + # Test feature set + await feat.set_value(second_effect) + call.assert_called() + await dev.update() + assert light_effect_module.effect == second_effect + assert feat.value == second_effect + call.reset_mock() + + with pytest.raises(ValueError, match="The effect foobar is not a built in effect."): + await light_effect_module.set_effect("foobar") + call.assert_not_called() + + +@light_effect +async def test_light_effect_brightness(dev: Device, mocker: MockerFixture): + """Test that light module uses light_effect for brightness when active.""" + light_module = dev.modules[Module.Light] + + light_effect = dev.modules[Module.LightEffect] + + await light_effect.set_effect(light_effect.LIGHT_EFFECTS_OFF) + await light_module.set_brightness(50) + await dev.update() + assert light_effect.effect == light_effect.LIGHT_EFFECTS_OFF + assert light_module.brightness == 50 + await light_effect.set_effect(light_effect.effect_list[1]) + await dev.update() + # assert light_module.brightness == 100 + + await light_module.set_brightness(75) + await dev.update() + assert light_module.brightness == 75 + + await light_effect.set_effect(light_effect.LIGHT_EFFECTS_OFF) + await dev.update() + assert light_module.brightness == 50 + + +@dimmable +async def test_light_brightness(dev: Device): + """Test brightness setter and getter.""" + assert isinstance(dev, Device) + light = next(get_parent_and_child_modules(dev, Module.Light)) + assert light + + # Test getting the value + feature = light.device.features["brightness"] + assert feature.minimum_value == 0 + assert feature.maximum_value == 100 + + await light.set_brightness(10) + await dev.update() + assert light.brightness == 10 + + with pytest.raises(ValueError, match="Invalid brightness value: "): + await light.set_brightness(feature.minimum_value - 10) + + with pytest.raises(ValueError, match="Invalid brightness value: "): + await light.set_brightness(feature.maximum_value + 10) + + +@variable_temp +async def test_light_color_temp(dev: Device): + """Test color temp setter and getter.""" + assert isinstance(dev, Device) + + light = next(get_parent_and_child_modules(dev, Module.Light)) + assert light + 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"] + assert isinstance(feature.minimum_value, int) + assert isinstance(feature.maximum_value, int) + + await light.set_color_temp(feature.minimum_value + 10) + await dev.update() + assert light.color_temp == feature.minimum_value + 10 + + # Test setting brightness with color temp + await light.set_brightness(50) + await dev.update() + assert light.brightness == 50 + + await light.set_color_temp(feature.minimum_value + 20, brightness=60) + await dev.update() + assert light.color_temp == feature.minimum_value + 20 + assert light.brightness == 60 + + with pytest.raises(ValueError, match=r"Temperature should be between \d+ and \d+"): + await light.set_color_temp(feature.minimum_value - 10) + + with pytest.raises(ValueError, match=r"Temperature should be between \d+ and \d+"): + await light.set_color_temp(feature.maximum_value + 10) + + +@light +async def test_light_set_state(dev: Device): + """Test brightness setter and getter.""" + assert isinstance(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): + await light_effect.set_effect(light_effect.LIGHT_EFFECTS_OFF) + + await light.set_state(LightState(light_on=False)) + await dev.update() + assert light.state.light_on is False + + await light.set_state(LightState(light_on=True)) + await dev.update() + assert light.state.light_on is True + + await light.set_state(LightState(brightness=0)) + await dev.update() + assert light.state.light_on is False + + await light.set_state(LightState(brightness=50)) + await dev.update() + assert light.state.light_on is True + + +@light_preset +async def test_light_preset_module(dev: Device, mocker: MockerFixture): + """Test light preset module.""" + preset_mod = next(get_parent_and_child_modules(dev, Module.LightPreset)) + assert preset_mod + light_mod = next(get_parent_and_child_modules(dev, Module.Light)) + assert light_mod + feat = preset_mod.device.features["light_preset"] + + preset_list = preset_mod.preset_list + assert "Not set" in preset_list + assert preset_list.index("Not set") == 0 + assert preset_list == feat.choices + + assert preset_mod.has_save_preset is True + + await light_mod.set_brightness(33) # Value that should not be a preset + await dev.update() + assert preset_mod.preset == "Not set" + assert feat.value == "Not set" + + if len(preset_list) == 1: + return + + call = mocker.spy(light_mod, "set_state") + second_preset = preset_list[1] + await preset_mod.set_preset(second_preset) + assert call.call_count == 1 + await dev.update() + assert preset_mod.preset == second_preset + assert feat.value == second_preset + + last_preset = preset_list[len(preset_list) - 1] + await preset_mod.set_preset(last_preset) + assert call.call_count == 2 + await dev.update() + assert preset_mod.preset == last_preset + assert feat.value == last_preset + + # Test feature set + await feat.set_value(second_preset) + assert call.call_count == 3 + await dev.update() + assert preset_mod.preset == second_preset + assert feat.value == second_preset + + with pytest.raises(ValueError, match="foobar is not a valid preset"): + await preset_mod.set_preset("foobar") + assert call.call_count == 3 + + +@light_preset +async def test_light_preset_save(dev: Device, mocker: MockerFixture): + """Test saving a new preset value.""" + preset_mod = next(get_parent_and_child_modules(dev, Module.LightPreset)) + assert preset_mod + preset_list = preset_mod.preset_list + if len(preset_list) == 1: + return + + second_preset = preset_list[1] + if preset_mod.preset_states_list[0].hue is None: + new_preset = LightState(brightness=52) + else: + new_preset = LightState(brightness=52, color_temp=3000, hue=20, saturation=30) + await preset_mod.save_preset(second_preset, new_preset) + await dev.update() + new_preset_state = preset_mod.preset_states_list[0] + assert new_preset_state.brightness == new_preset.brightness + assert new_preset_state.hue == new_preset.hue + assert new_preset_state.saturation == new_preset.saturation + assert new_preset_state.color_temp == new_preset.color_temp + + +@temp_control_smart +async def test_thermostat(dev: Device, mocker: MockerFixture): + """Test saving a new preset value.""" + therm_mod = next(get_parent_and_child_modules(dev, Module.Thermostat)) + assert therm_mod + + await therm_mod.set_state(False) + await dev.update() + assert therm_mod.state is False + assert therm_mod.mode is ThermostatState.Off + + await therm_mod.set_target_temperature(10) + await dev.update() + assert therm_mod.state is True + assert therm_mod.mode is ThermostatState.Heating + assert therm_mod.target_temperature == 10 + + target_temperature_feature = therm_mod.get_feature(therm_mod.set_target_temperature) + temp_control = dev.modules.get(Module.TemperatureControl) + assert temp_control + allowed_range = temp_control.allowed_temperature_range + assert target_temperature_feature.minimum_value == allowed_range[0] + assert target_temperature_feature.maximum_value == allowed_range[1] + + await therm_mod.set_temperature_unit("celsius") + await dev.update() + assert therm_mod.temperature_unit == "celsius" + + await therm_mod.set_temperature_unit("fahrenheit") + await dev.update() + assert therm_mod.temperature_unit == "fahrenheit" + + +async def test_set_time(dev: Device): + """Test setting the device time.""" + time_mod = dev.modules[Module.Time] + + original_time = time_mod.time + original_timezone = time_mod.timezone + + test_time = datetime.fromisoformat("2021-01-09 12:00:00+00:00") + test_time = test_time.astimezone(original_timezone) + + try: + assert time_mod.time != test_time + + await time_mod.set_time(test_time) + await dev.update() + assert time_mod.time == test_time + + if ( + isinstance(original_timezone, ZoneInfo) + and original_timezone.key != "Europe/Berlin" + ): + test_zonezone = ZoneInfo("Europe/Berlin") + else: + test_zonezone = ZoneInfo("Europe/London") + + # Just update the timezone + new_time = time_mod.time.astimezone(test_zonezone) + await time_mod.set_time(new_time) + await dev.update() + assert time_mod.time == new_time + finally: + # Reset back to the original + 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 new file mode 100644 index 000000000..2c001bc63 --- /dev/null +++ b/tests/test_device.py @@ -0,0 +1,419 @@ +"""Tests for all devices.""" + +from __future__ import annotations + +import importlib +import inspect +import pkgutil +import sys +import zoneinfo +from contextlib import AbstractContextManager, nullcontext +from unittest.mock import AsyncMock, patch + +import pytest + +import kasa +from kasa import Credentials, Device, DeviceConfig, DeviceType, KasaException, Module +from kasa.iot import ( + IotBulb, + IotCamera, + IotDevice, + IotDimmer, + IotLightStrip, + IotPlug, + IotStrip, + IotWallSwitch, +) +from kasa.iot.iottimezone import ( + TIMEZONE_INDEX, + get_timezone, + get_timezone_index, +) +from kasa.iot.modules import IotLightPreset +from kasa.smart import SmartChildDevice, SmartDevice +from kasa.smartcam import SmartCamChild, SmartCamDevice + + +def _get_subclasses(of_class): + package = sys.modules["kasa"] + subclasses = set() + for _, modname, _ in pkgutil.iter_modules(package.__path__): + importlib.import_module("." + modname, package="kasa") + module = sys.modules["kasa." + modname] + for name, obj in inspect.getmembers(module): + if ( + inspect.isclass(obj) + and issubclass(obj, of_class) + and module.__package__ != "kasa" + and module.__package__ != "kasa.interfaces" + ): + subclasses.add((module.__package__ + "." + name, obj)) + return sorted(subclasses) + + +device_classes = pytest.mark.parametrize( + "device_class_name_obj", _get_subclasses(Device), ids=lambda t: t[0] +) + + +async def test_device_id(dev: Device): + """Test all devices have a device id.""" + assert dev.device_id + + +async def test_alias(dev): + test_alias = "TEST1234" + original = dev.alias + + assert isinstance(original, str | None) + await dev.set_alias(test_alias) + await dev.update() + assert dev.alias == test_alias + + # If alias is None set it back to empty string + await dev.set_alias(original or "") + await dev.update() + assert dev.alias == original + + +@device_classes +async def test_device_class_ctors(device_class_name_obj): + """Make sure constructor api not broken for new and existing SmartDevices.""" + host = "127.0.0.2" + port = 1234 + credentials = Credentials("foo", "bar") + config = DeviceConfig(host, port_override=port, credentials=credentials) + klass = device_class_name_obj[1] + 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", **smartcam_required}, + { + "component_list": [{"id": "device", "ver_code": 1}], + "app_component_list": [{"name": "device", "version": 1}], + }, + ) + else: + dev = klass(host, config=config) + assert dev.host == host + assert dev.port == port + assert dev.credentials == credentials + + +@device_classes +async def test_device_class_repr(device_class_name_obj): + """Test device repr when update() not called and no discovery info.""" + host = "127.0.0.2" + port = 1234 + credentials = Credentials("foo", "bar") + config = DeviceConfig(host, port_override=port, credentials=credentials) + klass = device_class_name_obj[1] + if issubclass(klass, SmartChildDevice | SmartCamChild): + parent = SmartDevice(host, config=config) + dev = klass( + 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) + + CLASS_TO_DEFAULT_TYPE = { + IotDevice: DeviceType.Unknown, + IotBulb: DeviceType.Bulb, + IotPlug: DeviceType.Plug, + IotDimmer: DeviceType.Dimmer, + IotStrip: DeviceType.Strip, + IotWallSwitch: DeviceType.WallSwitch, + IotLightStrip: DeviceType.LightStrip, + IotCamera: DeviceType.Camera, + SmartChildDevice: DeviceType.Unknown, + SmartDevice: DeviceType.Unknown, + 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 in {SmartChildDevice, SmartCamChild} else not_child_repr + ) + assert repr(dev) == expected_repr + + +async def test_create_device_with_timeout(): + """Make sure timeout is passed to the protocol.""" + host = "127.0.0.1" + dev = IotDevice(host, config=DeviceConfig(host, timeout=100)) + assert dev.protocol._transport._timeout == 100 + dev = SmartDevice(host, config=DeviceConfig(host, timeout=100)) + assert dev.protocol._transport._timeout == 100 + + +async def test_create_thin_wrapper(): + """Make sure thin wrapper is created with the correct device type.""" + mock = AsyncMock() + config = DeviceConfig( + host="test_host", + port_override=1234, + timeout=100, + credentials=Credentials("username", "password"), + ) + with patch("kasa.device_factory.connect", return_value=mock) as connect: + dev = await Device.connect(config=config) + assert dev is mock + + connect.assert_called_once_with( + host=None, + config=config, + ) + + +@pytest.mark.parametrize( + ("device_class", "use_class"), kasa.deprecated_smart_devices.items() +) +def test_deprecated_devices(device_class, use_class): + package_name = ".".join(use_class.__module__.split(".")[:-1]) + msg = f"{device_class} is deprecated, use {use_class.__name__} from package {package_name} instead" + with pytest.deprecated_call(match=msg): + getattr(kasa, device_class) + packages = package_name.split(".") + module = __import__(packages[0]) + for _ in packages[1:]: + module = importlib.import_module(package_name, package=module.__name__) + getattr(module, use_class.__name__) + + +@pytest.mark.parametrize( + ("deprecated_class", "use_class"), kasa.deprecated_classes.items() +) +def test_deprecated_classes(deprecated_class, use_class): + msg = f"{deprecated_class} is deprecated, use {use_class.__name__} instead" + with pytest.deprecated_call(match=msg): + getattr(kasa, deprecated_class) + getattr(kasa, use_class.__name__) + + +deprecated_is_device_type = { + "is_bulb": DeviceType.Bulb, + "is_plug": DeviceType.Plug, + "is_dimmer": DeviceType.Dimmer, + "is_light_strip": DeviceType.LightStrip, + "is_wallswitch": DeviceType.WallSwitch, + "is_strip": DeviceType.Strip, + "is_strip_socket": DeviceType.StripSocket, +} +deprecated_is_light_function_smart_module = { + "is_color": "Color", + "is_dimmable": "Brightness", + "is_variable_color_temp": "ColorTemperature", +} + + +def test_deprecated_device_type_attributes(dev: SmartDevice): + """Test deprecated attributes on all devices.""" + + def _test_attr(attribute): + msg = f"{attribute} is deprecated" + if module := Device._deprecated_device_type_attributes[attribute][0]: + msg += f", use: {module} in device.modules instead" + with pytest.deprecated_call(match=msg): + val = getattr(dev, attribute) + return val + + for attribute in deprecated_is_device_type: + val = _test_attr(attribute) + expected_val = dev.device_type == deprecated_is_device_type[attribute] + assert val == expected_val + + +async def _test_attribute( + dev: Device, attribute_name, is_expected, module_name, *args, will_raise=False +): + if is_expected and will_raise: + ctx: AbstractContextManager | nullcontext = pytest.raises(will_raise) + dep_context: pytest.WarningsRecorder | nullcontext = pytest.deprecated_call( + match=(f"{attribute_name} is deprecated, use:") + ) + 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}'" + ) + dep_context = nullcontext() + + with dep_context, ctx: + if args: + await getattr(dev, attribute_name)(*args) + else: + attribute_val = getattr(dev, attribute_name) + assert attribute_val is not None + + +async def test_deprecated_light_effect_attributes(dev: Device): + light_effect = dev.modules.get(Module.LightEffect) + + await _test_attribute(dev, "effect", bool(light_effect), "LightEffect") + await _test_attribute(dev, "effect_list", bool(light_effect), "LightEffect") + await _test_attribute(dev, "set_effect", bool(light_effect), "LightEffect", "Off") + exc = ( + NotImplementedError + if light_effect and not light_effect.has_custom_effects + else None + ) + await _test_attribute( + dev, + "set_custom_effect", + bool(light_effect), + "LightEffect", + {"enable": 0, "name": "foo", "id": "bar"}, + will_raise=exc, + ) + + +async def test_deprecated_light_attributes(dev: Device): + light = dev.modules.get(Module.Light) + + await _test_attribute(dev, "is_dimmable", bool(light), "Light") + 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.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.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.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 + ) + await _test_attribute( + dev, "valid_temperature_range", bool(light), "Light", will_raise=exc + ) + + await _test_attribute(dev, "has_effects", bool(light), "Light") + + +async def test_deprecated_other_attributes(dev: Device): + led_module = dev.modules.get(Module.Led) + + await _test_attribute(dev, "led", bool(led_module), "Led") + await _test_attribute(dev, "set_led", bool(led_module), "Led", True) + await _test_attribute(dev, "supported_modules", True, None) + + +async def test_deprecated_emeter_attributes(dev: Device): + energy_module = dev.modules.get(Module.Energy) + + await _test_attribute(dev, "get_emeter_realtime", bool(energy_module), "Energy") + await _test_attribute(dev, "emeter_realtime", bool(energy_module), "Energy") + await _test_attribute(dev, "emeter_today", bool(energy_module), "Energy") + await _test_attribute(dev, "emeter_this_month", bool(energy_module), "Energy") + await _test_attribute(dev, "current_consumption", bool(energy_module), "Energy") + await _test_attribute(dev, "get_emeter_daily", bool(energy_module), "Energy") + await _test_attribute(dev, "get_emeter_monthly", bool(energy_module), "Energy") + + +async def test_deprecated_light_preset_attributes(dev: Device): + preset = dev.modules.get(Module.LightPreset) + + exc: type[AttributeError] | type[KasaException] | None = ( + AttributeError if not preset else None + ) + await _test_attribute(dev, "presets", bool(preset), "LightPreset", will_raise=exc) + + exc = None + is_expected = bool(preset) + # deprecated save_preset not implemented for smart devices as it's unlikely anyone + # has an existing reliance on this for the newer devices. + if isinstance(dev, SmartDevice): + is_expected = False + + if preset and len(preset.preset_states_list) == 0: + exc = KasaException + + await _test_attribute( + dev, + "save_preset", + is_expected, + "LightPreset", + IotLightPreset(index=0, hue=100, brightness=100, saturation=0, color_temp=0), # type: ignore[call-arg] + will_raise=exc, + ) + + +async def test_device_type_aliases(): + """Test that the device type aliases in Device work.""" + + def _mock_connect(config, *args, **kwargs): + mock = AsyncMock() + mock.config = config + return mock + + with patch("kasa.device_factory.connect", side_effect=_mock_connect): + dev = await Device.connect( + config=Device.Config( + host="127.0.0.1", + credentials=Device.Credentials(username="user", password="foobar"), # noqa: S106 + connection_type=Device.ConnectionParameters( + device_family=Device.Family.SmartKasaPlug, + encryption_type=Device.EncryptionType.Klap, + login_version=2, + ), + ) + ) + assert isinstance(dev.config, DeviceConfig) + assert DeviceType.Dimmer == Device.Type.Dimmer + + +async def test_device_timezones(): + """Test the timezone data is good.""" + # Check all indexes return a zoneinfo + for i in range(110): + tz = await get_timezone(i) + assert tz + assert tz != zoneinfo.ZoneInfo("Etc/UTC"), f"{i} is default Etc/UTC" + + # Check an unexpected index returns a UTC default. + tz = await get_timezone(110) + assert tz == zoneinfo.ZoneInfo("Etc/UTC") + + # Get an index from a timezone + for index, zone in TIMEZONE_INDEX.items(): + zone_info = zoneinfo.ZoneInfo(zone) + found_index = await get_timezone_index(zone_info) + assert found_index == index + + # Try a timezone not hardcoded finds another match + index = await get_timezone_index(zoneinfo.ZoneInfo("Asia/Katmandu")) + assert index == 77 + + # Try a timezone not hardcoded no match + with pytest.raises(zoneinfo.ZoneInfoNotFoundError): + await get_timezone_index(zoneinfo.ZoneInfo("Foo/bar")) diff --git a/tests/test_device_factory.py b/tests/test_device_factory.py new file mode 100644 index 000000000..19ccfb73d --- /dev/null +++ b/tests/test_device_factory.py @@ -0,0 +1,297 @@ +"""Module for testing device factory. + +As this module tests the factory with discovery data and expects update to be +called on devices it uses the discovery_mock handles all the patching of the +query methods without actually replacing the device protocol class with one of +the testing fake protocols. +""" + +import logging +from typing import cast + +import aiohttp +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, + IotDevice, + SmartCamDevice, + SmartDevice, + connect, + get_device_class_from_family, + get_protocol, +) +from kasa.deviceconfig import ( + DeviceConfig, + DeviceConnectionParameters, + DeviceEncryptionType, + DeviceFamily, +) +from kasa.discover import DiscoveryResult +from kasa.transports import ( + AesTransport, + BaseTransport, + KlapTransport, + KlapTransportV2, + LinkieTransportV2, + SslAesTransport, + SslTransport, + XorTransport, +) + +from .conftest import DISCOVERY_MOCK_IP + +# Device Factory tests are not relevant for real devices which run against +# a single device that has already been created via the factory. +pytestmark = [pytest.mark.requires_dummy] + + +def _get_connection_type_device_class(discovery_info): + if "result" in discovery_info: + device_class = Discover._get_device_class(discovery_info) + dr = DiscoveryResult.from_dict(discovery_info["result"]) + + connection_type = Discover._get_connection_parameters(dr) + else: + connection_type = DeviceConnectionParameters.from_values( + DeviceFamily.IotSmartPlugSwitch.value, DeviceEncryptionType.Xor.value + ) + device_class = Discover._get_device_class(discovery_info) + + return connection_type, device_class + + +async def test_connect( + discovery_mock, + mocker, +): + """Test that if the protocol is passed in it gets set correctly.""" + host = DISCOVERY_MOCK_IP + ctype, device_class = _get_connection_type_device_class( + discovery_mock.discovery_data + ) + + config = DeviceConfig( + host=host, credentials=Credentials("foor", "bar"), connection_type=ctype + ) + protocol_class = get_protocol(config).__class__ + close_mock = mocker.patch.object(protocol_class, "close") + # mocker.patch.object(SmartDevice, "update") + # mocker.patch.object(Device, "update") + dev = await connect( + config=config, + ) + assert isinstance(dev, device_class) + assert isinstance(dev.protocol, protocol_class) + + assert dev.config == config + assert close_mock.call_count == 0 + await dev.disconnect() + assert close_mock.call_count == 1 + + +@pytest.mark.parametrize("custom_port", [123, None]) +async def test_connect_custom_port(discovery_mock, mocker, custom_port): + """Make sure that connect returns an initialized SmartDevice instance.""" + host = DISCOVERY_MOCK_IP + + discovery_data = discovery_mock.discovery_data + ctype, _ = _get_connection_type_device_class(discovery_data) + config = DeviceConfig( + host=host, + port_override=custom_port, + connection_type=ctype, + credentials=Credentials("dummy_user", "dummy_password"), + ) + default_port = discovery_mock.default_port + + ctype, _ = _get_connection_type_device_class(discovery_data) + + dev = await connect(config=config) + assert issubclass(dev.__class__, Device) + assert dev.port == custom_port or dev.port == default_port + + +@pytest.mark.xdist_group(name="caplog") +async def test_connect_logs_connect_time( + discovery_mock, + caplog: pytest.LogCaptureFixture, +): + """Test that the connect time is logged when debug logging is enabled.""" + discovery_data = discovery_mock.discovery_data + ctype, _ = _get_connection_type_device_class(discovery_data) + + host = DISCOVERY_MOCK_IP + config = DeviceConfig( + host=host, credentials=Credentials("foor", "bar"), connection_type=ctype + ) + logging.getLogger("kasa").setLevel(logging.DEBUG) + await connect( + config=config, + ) + assert "seconds to update" in caplog.text + + +async def test_connect_query_fails(discovery_mock, mocker): + """Make sure that connect fails when query fails.""" + host = DISCOVERY_MOCK_IP + discovery_data = discovery_mock.discovery_data + mocker.patch("kasa.IotProtocol.query", side_effect=KasaException) + mocker.patch("kasa.SmartProtocol.query", side_effect=KasaException) + + ctype, _ = _get_connection_type_device_class(discovery_data) + config = DeviceConfig( + host=host, credentials=Credentials("foor", "bar"), connection_type=ctype + ) + protocol_class = get_protocol(config).__class__ + close_mock = mocker.patch.object(protocol_class, "close") + assert close_mock.call_count == 0 + with pytest.raises(KasaException): + await connect(config=config) + assert close_mock.call_count == 1 + + +async def test_connect_http_client(discovery_mock, mocker): + """Make sure that discover_single returns an initialized SmartDevice instance.""" + host = DISCOVERY_MOCK_IP + discovery_data = discovery_mock.discovery_data + ctype, _ = _get_connection_type_device_class(discovery_data) + + http_client = aiohttp.ClientSession() + + config = DeviceConfig( + host=host, credentials=Credentials("foor", "bar"), connection_type=ctype + ) + dev = await connect(config=config) + if ctype.encryption_type != DeviceEncryptionType.Xor: + assert dev.protocol._transport._http_client.client != http_client + await dev.disconnect() + + config = DeviceConfig( + host=host, + credentials=Credentials("foor", "bar"), + connection_type=ctype, + http_client=http_client, + ) + dev = await connect(config=config) + if ctype.encryption_type != DeviceEncryptionType.Xor: + assert dev.protocol._transport._http_client.client == http_client + await dev.disconnect() + await http_client.close() + + +async def test_device_types(dev: Device): + await dev.update() + if isinstance(dev, SmartCamDevice): + res = SmartCamDevice._get_device_type_from_sysinfo(dev.sys_info) + elif isinstance(dev, SmartDevice): + assert dev._discovery_info + device_type = cast(str, dev._discovery_info["device_type"]) + res = SmartDevice._get_device_type_from_components( + list(dev._components.keys()), device_type + ) + else: + res = IotDevice._get_device_type_from_sys_info(dev._last_update) + + assert dev.device_type == res + + +@pytest.mark.xdist_group(name="caplog") +async def test_device_class_from_unknown_family(caplog): + """Verify that unknown SMART devices yield a warning and fallback to SmartDevice.""" + dummy_name = "SMART.foo" + 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_device_type.py b/tests/test_device_type.py new file mode 100644 index 000000000..099f08626 --- /dev/null +++ b/tests/test_device_type.py @@ -0,0 +1,23 @@ +from kasa.device_type import DeviceType + + +async def test_device_type_from_value(): + """Make sure that every device type can be created from its value.""" + for name in DeviceType: + assert DeviceType.from_value(name.value) is not None + + assert DeviceType.from_value("nonexistent") is DeviceType.Unknown + assert DeviceType.from_value("plug") is DeviceType.Plug + assert DeviceType.Plug.value == "plug" + + assert DeviceType.from_value("bulb") is DeviceType.Bulb + assert DeviceType.Bulb.value == "bulb" + + assert DeviceType.from_value("dimmer") is DeviceType.Dimmer + assert DeviceType.Dimmer.value == "dimmer" + + assert DeviceType.from_value("strip") is DeviceType.Strip + assert DeviceType.Strip.value == "strip" + + assert DeviceType.from_value("lightstrip") is DeviceType.LightStrip + assert DeviceType.LightStrip.value == "lightstrip" diff --git a/tests/test_deviceconfig.py b/tests/test_deviceconfig.py new file mode 100644 index 000000000..aebdd3a61 --- /dev/null +++ b/tests/test_deviceconfig.py @@ -0,0 +1,136 @@ +import json +from dataclasses import replace +from json import dumps as json_dumps +from json import loads as json_loads + +import aiohttp +import pytest +from mashumaro import MissingField + +from kasa.credentials import Credentials +from kasa.deviceconfig import ( + DeviceConfig, + DeviceConnectionParameters, + DeviceEncryptionType, + DeviceFamily, +) + +from .conftest import load_fixture + +PLUG_XOR_CONFIG = DeviceConfig(host="127.0.0.1") +PLUG_KLAP_CONFIG = DeviceConfig( + host="127.0.0.1", + connection_type=DeviceConnectionParameters( + DeviceFamily.SmartTapoPlug, DeviceEncryptionType.Klap, login_version=2 + ), +) +CAMERA_AES_CONFIG = DeviceConfig( + host="127.0.0.1", + connection_type=DeviceConnectionParameters( + DeviceFamily.SmartIpCamera, DeviceEncryptionType.Aes, https=True + ), +) + + +async def test_serialization(): + """Test device config serialization.""" + config = DeviceConfig(host="Foo", http_client=aiohttp.ClientSession()) + config_dict = config.to_dict() + config_json = json_dumps(config_dict) + config2_dict = json_loads(config_json) + config2 = DeviceConfig.from_dict(config2_dict) + assert config == config2 + assert config.to_dict_control_credentials() == config.to_dict() + + +@pytest.mark.parametrize( + ("fixture_name", "expected_value"), + [ + ("deviceconfig_plug-xor.json", PLUG_XOR_CONFIG), + ("deviceconfig_plug-klap.json", PLUG_KLAP_CONFIG), + ("deviceconfig_camera-aes-https.json", CAMERA_AES_CONFIG), + ], + ids=lambda arg: arg.split("_")[-1] if isinstance(arg, str) else "", +) +async def test_deserialization(fixture_name: str, expected_value: DeviceConfig): + """Test device config deserialization.""" + dict_val = json.loads(load_fixture("serialization", fixture_name)) + config = DeviceConfig.from_dict(dict_val) + assert config == expected_value + assert expected_value.to_dict() == dict_val + + +async def test_serialization_http_client(): + """Test that the http client does not try to serialize.""" + dict_val = json.loads(load_fixture("serialization", "deviceconfig_plug-klap.json")) + + config = replace(PLUG_KLAP_CONFIG, http_client=object()) + assert config.http_client + + assert config.to_dict() == dict_val + + +async def test_conn_param_no_https(): + """Test no https in connection param defaults to False.""" + dict_val = { + "device_family": "SMART.TAPOPLUG", + "encryption_type": "KLAP", + "login_version": 2, + } + param = DeviceConnectionParameters.from_dict(dict_val) + assert param.https is False + assert param.to_dict() == {**dict_val, "https": False} + + +@pytest.mark.parametrize( + ("input_value", "expected_error"), + [ + ({"Foo": "Bar"}, MissingField), + ("foobar", ValueError), + ], + ids=["invalid-dict", "not-dict"], +) +def test_deserialization_errors(input_value, expected_error): + with pytest.raises(expected_error): + DeviceConfig.from_dict(input_value) + + +async def test_credentials_hash(): + config = DeviceConfig( + host="Foo", + http_client=aiohttp.ClientSession(), + credentials=Credentials("foo", "bar"), + ) + config_dict = config.to_dict_control_credentials(credentials_hash="credhash") + config_json = json_dumps(config_dict) + config2_dict = json_loads(config_json) + config2 = DeviceConfig.from_dict(config2_dict) + assert config2.credentials_hash == "credhash" + assert config2.credentials is None + + +async def test_blank_credentials_hash(): + config = DeviceConfig( + host="Foo", + http_client=aiohttp.ClientSession(), + credentials=Credentials("foo", "bar"), + ) + config_dict = config.to_dict_control_credentials(credentials_hash="") + config_json = json_dumps(config_dict) + config2_dict = json_loads(config_json) + config2 = DeviceConfig.from_dict(config2_dict) + assert config2.credentials_hash is None + assert config2.credentials is None + + +async def test_exclude_credentials(): + config = DeviceConfig( + host="Foo", + http_client=aiohttp.ClientSession(), + credentials=Credentials("foo", "bar"), + ) + config_dict = config.to_dict_control_credentials(exclude_credentials=True) + config_json = json_dumps(config_dict) + config2_dict = json_loads(config_json) + config2 = DeviceConfig.from_dict(config2_dict) + assert config2.credentials is None diff --git a/tests/test_devtools.py b/tests/test_devtools.py new file mode 100644 index 000000000..b49268d33 --- /dev/null +++ b/tests/test_devtools.py @@ -0,0 +1,159 @@ +"""Module for dump_devinfo tests.""" + +import copy + +import pytest + +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, +) + +smart_fixtures = parametrize( + "smart fixtures", protocol_filter={"SMART"}, fixture_name="fixture_info" +) +smartcam_fixtures = parametrize( + "smartcam fixtures", protocol_filter={"SMARTCAM"}, fixture_name="fixture_info" +) +iot_fixtures = parametrize( + "iot fixtures", protocol_filter={"IOT"}, fixture_name="fixture_info" +) + + +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", {}).get("result"), + ) + elif fixture_info.protocol in {"SMART"}: + device_info = SmartDevice._get_device_info( + 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) + else: + device_info = IotDevice._get_device_info(fixture_info.data, None) + + region = f"({device_info.region})" if device_info.region else "" + expected = f"{device_info.long_name}{region}_{device_info.hardware_version}_{device_info.firmware_version}.json" + assert fixture_info.name == expected + + +@smart_fixtures +async def test_smart_fixtures(fixture_info: FixtureInfo): + """Test that smart fixtures are created the same.""" + dev = await get_device_for_fixture(fixture_info, verbatim=True) + assert isinstance(dev, SmartDevice) + if dev.children: + pytest.skip("Test not currently implemented for devices with children.") + fixtures = await get_smart_fixtures( + dev.protocol, + discovery_info=fixture_info.data.get("discovery_result"), + batch_size=5, + ) + fixture_result = fixtures[0] + + 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) + + created_fixtures = await get_smart_fixtures( + dev.protocol, + discovery_info=fixture_info.data.get("discovery_result"), + batch_size=5, + ) + 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): + """Test that iot fixtures are created the same.""" + # Iot fixtures often do not have enough data to perform a device update() + # without missing info being added to suppress the update + dev = await get_device_for_fixture( + fixture_info, verbatim=True, update_after_init=False + ) + assert isinstance(dev.protocol, IotProtocol) + + fixture = await get_legacy_fixture( + dev.protocol, discovery_info=fixture_info.data.get("discovery_result") + ) + fixture_result = fixture + + created_fixture = { + key: val for key, val in fixture_result.data.items() if "err_code" not in val + } + saved_fixture = { + key: val for key, val in fixture_info.data.items() if "err_code" not in val + } + assert saved_fixture == created_fixture diff --git a/tests/test_discovery.py b/tests/test_discovery.py new file mode 100644 index 000000000..6fc521b09 --- /dev/null +++ b/tests/test_discovery.py @@ -0,0 +1,751 @@ +# type: ignore +# ruff: noqa: S106 + +import asyncio +import base64 +import json +import logging +import re +import socket +from asyncio import timeout as asyncio_timeout +from unittest.mock import MagicMock + +import aiohttp +import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import padding as asymmetric_padding + +from kasa import ( + Credentials, + Device, + DeviceType, + Discover, + IotProtocol, + KasaException, +) +from kasa.device_factory import ( + get_device_class_from_family, + get_device_class_from_sys_info, + get_protocol, +) +from kasa.deviceconfig import ( + DeviceConfig, + DeviceConnectionParameters, +) +from kasa.discover import ( + DiscoveryResult, + _AesDiscoveryQuery, + _DiscoverProtocol, + json_dumps, +) +from kasa.exceptions import AuthenticationError, UnsupportedDeviceError +from kasa.iot import IotDevice, IotPlug +from kasa.transports.aestransport import AesEncyptionSession +from kasa.transports.xortransport import XorEncryption, XorTransport + +from .conftest import ( + bulb_iot, + dimmer_iot, + lightstrip_iot, + new_discovery, + plug_iot, + strip_iot, + wallswitch_iot, +) + +# A physical device has to respond to discovery for the tests to work. +pytestmark = [pytest.mark.requires_dummy] + +UNSUPPORTED = { + "result": { + "device_id": "xx", + "owner": "xx", + "device_type": "SMART.TAPOXMASTREE", + "device_model": "P110(EU)", + "ip": "127.0.0.1", + "mac": "48-22xxx", + "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, +} + + +@wallswitch_iot +async def test_type_detection_switch(dev: Device): + d = Discover._get_device_class(dev._last_update)("localhost") + with pytest.deprecated_call(match="use device_type property instead"): + assert d.is_wallswitch + assert d.device_type is DeviceType.WallSwitch + + +@plug_iot +async def test_type_detection_plug(dev: Device): + d = Discover._get_device_class(dev._last_update)("localhost") + assert d.device_type == DeviceType.Plug + + +@bulb_iot +async def test_type_detection_bulb(dev: Device): + d = Discover._get_device_class(dev._last_update)("localhost") + # TODO: light_strip is a special case for now to force bulb tests on it + + if d.device_type is not DeviceType.LightStrip: + assert d.device_type == DeviceType.Bulb + + +@strip_iot +async def test_type_detection_strip(dev: Device): + d = Discover._get_device_class(dev._last_update)("localhost") + assert d.device_type == DeviceType.Strip + + +@dimmer_iot +async def test_type_detection_dimmer(dev: Device): + d = Discover._get_device_class(dev._last_update)("localhost") + assert d.device_type == DeviceType.Dimmer + + +@lightstrip_iot +async def test_type_detection_lightstrip(dev: Device): + d = Discover._get_device_class(dev._last_update)("localhost") + assert d.device_type == DeviceType.LightStrip + + +@pytest.mark.xdist_group(name="caplog") +async def test_type_unknown(caplog): + invalid_info = {"system": {"get_sysinfo": {"type": "nosuchtype"}}} + assert Discover._get_device_class(invalid_info) is IotPlug + msg = "Unknown device type nosuchtype, falling back to plug" + assert msg in caplog.text + + +@pytest.mark.parametrize("custom_port", [123, None]) +async def test_discover_single(discovery_mock, custom_port, mocker): + """Make sure that discover_single returns an initialized SmartDevice instance.""" + host = "127.0.0.1" + discovery_mock.ip = host + discovery_mock.port_override = custom_port + + 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") + + x = await Discover.discover_single( + host, port=custom_port, credentials=Credentials() + ) + assert issubclass(x.__class__, Device) + assert x._discovery_info is not None + 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 != 9999: + assert x.alias is None + + ct = DeviceConnectionParameters.from_values( + discovery_mock.device_type, + discovery_mock.encrypt_type, + login_version=discovery_mock.login_version, + https=discovery_mock.https, + http_port=discovery_mock.http_port, + ) + config = DeviceConfig( + host=host, + port_override=custom_port, + connection_type=ct, + credentials=Credentials(), + ) + assert x.config == config + + +async def test_discover_single_hostname(discovery_mock, mocker): + """Make sure that discover_single returns an initialized SmartDevice instance.""" + host = "foobar" + ip = "127.0.0.1" + + discovery_mock.ip = ip + device_class = Discover._get_device_class(discovery_mock.discovery_data) + update_mock = mocker.patch.object(device_class, "update") + + x = await Discover.discover_single(host, credentials=Credentials()) + assert issubclass(x.__class__, Device) + assert x._discovery_info is not None + assert x.host == host + assert update_mock.call_count == 0 + + mocker.patch("socket.getaddrinfo", side_effect=socket.gaierror()) + with pytest.raises(KasaException): + x = await Discover.discover_single(host, credentials=Credentials()) + + +async def test_discover_credentials(mocker): + """Make sure that discover gives credentials precedence over un and pw.""" + host = "127.0.0.1" + + async def mock_discover(self, *_, **__): + self.discovered_devices = {host: MagicMock()} + self.seen_hosts.add(host) + self._handle_discovered_event() + + mocker.patch.object(_DiscoverProtocol, "do_discover", new=mock_discover) + dp = mocker.spy(_DiscoverProtocol, "__init__") + + # Only credentials passed + await Discover.discover(credentials=Credentials(), timeout=0) + assert dp.mock_calls[0].kwargs["credentials"] == Credentials() + # Credentials and un/pw passed + await Discover.discover( + credentials=Credentials(), username="Foo", password="Bar", timeout=0 + ) + assert dp.mock_calls[1].kwargs["credentials"] == Credentials() + # Only un/pw passed + await Discover.discover(username="Foo", password="Bar", timeout=0) + assert dp.mock_calls[2].kwargs["credentials"] == Credentials("Foo", "Bar") + # Only un passed, credentials should be None + await Discover.discover(username="Foo", timeout=0) + assert dp.mock_calls[3].kwargs["credentials"] is None + + +async def test_discover_single_credentials(mocker): + """Make sure that discover_single gives credentials precedence over un and pw.""" + host = "127.0.0.1" + + async def mock_discover(self, *_, **__): + self.discovered_devices = {host: MagicMock()} + self.seen_hosts.add(host) + self._handle_discovered_event() + + mocker.patch.object(_DiscoverProtocol, "do_discover", new=mock_discover) + dp = mocker.spy(_DiscoverProtocol, "__init__") + + # Only credentials passed + await Discover.discover_single(host, credentials=Credentials(), timeout=0) + assert dp.mock_calls[0].kwargs["credentials"] == Credentials() + # Credentials and un/pw passed + await Discover.discover_single( + host, credentials=Credentials(), username="Foo", password="Bar", timeout=0 + ) + assert dp.mock_calls[1].kwargs["credentials"] == Credentials() + # Only un/pw passed + await Discover.discover_single(host, username="Foo", password="Bar", timeout=0) + assert dp.mock_calls[2].kwargs["credentials"] == Credentials("Foo", "Bar") + # Only un passed, credentials should be None + await Discover.discover_single(host, username="Foo", timeout=0) + assert dp.mock_calls[3].kwargs["credentials"] is None + + +async def test_discover_single_unsupported(unsupported_device_info, mocker): + """Make sure that discover_single handles unsupported devices correctly.""" + host = "127.0.0.1" + + # Test with a valid unsupported response + with pytest.raises( + UnsupportedDeviceError, + ): + await Discover.discover_single(host) + + +async def test_discover_single_no_response(mocker): + """Make sure that discover_single handles no response correctly.""" + host = "127.0.0.1" + mocker.patch.object(_DiscoverProtocol, "do_discover") + with pytest.raises( + KasaException, match=f"Timed out getting discovery response for {host}" + ): + await Discover.discover_single(host, discovery_timeout=0) + + +INVALIDS = [ + ("No 'system' or 'get_sysinfo' in response", {"no": "data"}), + ( + "Unable to find the device type field", + {"system": {"get_sysinfo": {"missing_type": 1}}}, + ), +] + + +@pytest.mark.parametrize(("msg", "data"), INVALIDS) +async def test_discover_invalid_info(msg, data, mocker): + """Make sure that invalid discovery information raises an exception.""" + host = "127.0.0.1" + + async def mock_discover(self): + self.datagram_received( + XorEncryption.encrypt(json_dumps(data))[4:], (host, 9999) + ) + + mocker.patch.object(_DiscoverProtocol, "do_discover", mock_discover) + + with pytest.raises(KasaException, match=msg): + await Discover.discover_single(host) + + +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 * discovery_ports + + +async def test_discover_datagram_received(mocker, discovery_data): + """Verify that datagram received fills discovered_devices.""" + proto = _DiscoverProtocol() + + mocker.patch.object(XorEncryption, "decrypt") + + addr = "127.0.0.1" + port = 20002 if "result" in discovery_data else 9999 + + mocker.patch("kasa.discover.json_loads", return_value=discovery_data) + proto.datagram_received("", (addr, port)) + + addr2 = "127.0.0.2" + mocker.patch("kasa.discover.json_loads", return_value=UNSUPPORTED) + proto.datagram_received("", (addr2, 20002)) + + # Check that device in discovered_devices is initialized correctly + assert len(proto.discovered_devices) == 1 + # Check that unsupported device is 1 + assert len(proto.unsupported_device_exceptions) == 1 + dev = proto.discovered_devices[addr] + assert issubclass(dev.__class__, Device) + assert dev.host == addr + + +@pytest.mark.parametrize(("msg", "data"), INVALIDS) +async def test_discover_invalid_responses(msg, data, mocker): + """Verify that we don't crash whole discovery if some devices in the network are sending unexpected data.""" + proto = _DiscoverProtocol() + mocker.patch("kasa.discover.json_loads", return_value=data) + mocker.patch.object(XorEncryption, "encrypt") + mocker.patch.object(XorEncryption, "decrypt") + + proto.datagram_received(data, ("127.0.0.1", 9999)) + assert len(proto.discovered_devices) == 0 + + +AUTHENTICATION_DATA_KLAP = { + "result": { + "device_id": "xx", + "owner": "xx", + "device_type": "IOT.SMARTPLUGSWITCH", + "device_model": "HS100(UK)", + "ip": "127.0.0.1", + "mac": "12-34-56-78-90-AB", + "is_support_iot_cloud": True, + "obd_src": "tplink", + "factory_default": False, + "mgt_encrypt_schm": { + "is_support_https": False, + "encrypt_type": "KLAP", + "http_port": 80, + }, + }, + "error_code": 0, +} + + +@new_discovery +async def test_discover_single_authentication(discovery_mock, mocker): + """Make sure that discover_single handles authenticating devices correctly.""" + host = "127.0.0.1" + discovery_mock.ip = host + device_class = Discover._get_device_class(discovery_mock.discovery_data) + mocker.patch.object( + device_class, + "update", + side_effect=AuthenticationError("Failed to authenticate"), + ) + + with pytest.raises( # noqa: PT012 + AuthenticationError, + match="Failed to authenticate", + ): + device = await Discover.discover_single( + host, credentials=Credentials("foo", "bar") + ) + await device.update() + + mocker.patch.object(device_class, "update") + device = await Discover.discover_single(host, credentials=Credentials("foo", "bar")) + await device.update() + assert isinstance(device, device_class) + + +@new_discovery +async def test_device_update_from_new_discovery_info(discovery_mock): + """Make sure that new discovery devices update from discovery info correctly.""" + discovery_data = discovery_mock.discovery_data + device_class = Discover._get_device_class(discovery_data) + device = device_class("127.0.0.1") + discover_info = DiscoveryResult.from_dict(discovery_data["result"]) + + 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): + with pytest.raises( + KasaException, + match=re.escape("You need to await update() to access the data"), + ): + assert device.supported_modules + + +async def test_discover_single_http_client(discovery_mock, mocker): + """Make sure that discover_single returns an initialized SmartDevice instance.""" + host = "127.0.0.1" + discovery_mock.ip = host + + http_client = aiohttp.ClientSession() + + x: Device = await Discover.discover_single(host) + + assert x.config.uses_http == (discovery_mock.default_port != 9999) + + 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 + + +async def test_discover_http_client(discovery_mock, mocker): + """Make sure that discover returns an initialized SmartDevice instance.""" + host = "127.0.0.1" + discovery_mock.ip = host + + http_client = aiohttp.ClientSession() + + devices = await Discover.discover(discovery_timeout=0) + x: Device = devices[host] + assert x.config.uses_http == (discovery_mock.default_port != 9999) + + 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 + + +LEGACY_DISCOVER_DATA = { + "system": { + "get_sysinfo": { + "alias": "#MASKED_NAME#", + "dev_name": "Smart Wi-Fi Plug", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "hwId": "00000000000000000000000000000000", + "hw_ver": "0.0", + "mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "HS100(UK)", + "sw_ver": "1.1.0 Build 201016 Rel.175121", + "updating": 0, + } + } +} + + +class FakeDatagramTransport(asyncio.DatagramTransport): + GHOST_PORT = 8888 + + def __init__(self, dp, port, do_not_reply_count, unsupported=False): + self.dp = dp + self.port = port + self.do_not_reply_count = do_not_reply_count + self.send_count = 0 + if port == 9999: + self.datagram = XorEncryption.encrypt(json_dumps(LEGACY_DISCOVER_DATA))[4:] + elif port == 20002: + discovery_data = UNSUPPORTED if unsupported else AUTHENTICATION_DATA_KLAP + self.datagram = ( + b"\x02\x00\x00\x01\x01[\x00\x00\x00\x00\x00\x00W\xcev\xf8" + + json_dumps(discovery_data).encode() + ) + else: + self.datagram = {"foo": "bar"} + + def get_extra_info(self, name, default=None): + return MagicMock() + + def sendto(self, data, addr=None): + ip, port = addr + if port == self.port or self.port == self.GHOST_PORT: + self.send_count += 1 + if self.send_count > self.do_not_reply_count: + self.dp.datagram_received(self.datagram, (ip, self.port)) + + +@pytest.mark.parametrize("port", [9999, 20002]) +@pytest.mark.parametrize("do_not_reply_count", [0, 1, 2, 3, 4]) +async def test_do_discover_drop_packets(mocker, port, do_not_reply_count): + """Make sure that _DiscoverProtocol handles authenticating devices correctly.""" + host = "127.0.0.1" + discovery_timeout = 0 + + dp = _DiscoverProtocol( + target=host, + discovery_timeout=discovery_timeout, + discovery_packets=5, + ) + ft = FakeDatagramTransport(dp, port, do_not_reply_count) + dp.connection_made(ft) + + await dp.wait_for_discovery_to_complete() + + await asyncio.sleep(0) + assert ft.send_count == do_not_reply_count + 1 + assert dp.discover_task.done() + assert dp.discover_task.cancelled() + + +@pytest.mark.parametrize( + ("port", "will_timeout"), + [(FakeDatagramTransport.GHOST_PORT, True), (20002, False)], + ids=["unknownport", "unsupporteddevice"], +) +async def test_do_discover_invalid(mocker, port, will_timeout): + """Make sure that _DiscoverProtocol handles invalid devices correctly.""" + host = "127.0.0.1" + discovery_timeout = 0 + + dp = _DiscoverProtocol( + target=host, + discovery_timeout=discovery_timeout, + discovery_packets=5, + ) + ft = FakeDatagramTransport(dp, port, 0, unsupported=True) + dp.connection_made(ft) + + await dp.wait_for_discovery_to_complete() + await asyncio.sleep(0) + assert dp.discover_task.done() + assert dp.discover_task.cancelled() != will_timeout + + +async def test_discover_propogates_task_exceptions(discovery_mock): + """Make sure that discover propogates callback exceptions.""" + discovery_timeout = 0 + + async def on_discovered(dev): + raise KasaException("Dummy exception") + + with pytest.raises(KasaException): + await Discover.discover( + discovery_timeout=discovery_timeout, on_discovered=on_discovered + ) + + +async def test_do_discover_no_connection(mocker): + """Make sure that if the datagram connection doesnt start a TimeoutError is raised.""" + host = "127.0.0.1" + discovery_timeout = 0 + mocker.patch.object(_DiscoverProtocol, "DISCOVERY_START_TIMEOUT", 0) + dp = _DiscoverProtocol( + target=host, + discovery_timeout=discovery_timeout, + discovery_packets=5, + ) + # Normally tests would simulate connection as per below + # ft = FakeDatagramTransport(dp, port, 0, unsupported=True) + # dp.connection_made(ft) + + with pytest.raises(asyncio.TimeoutError): + await dp.wait_for_discovery_to_complete() + + +async def test_do_discover_external_cancel(mocker): + """Make sure that a cancel other than when target is discovered propogates.""" + host = "127.0.0.1" + discovery_timeout = 1 + + dp = _DiscoverProtocol( + target=host, + discovery_timeout=discovery_timeout, + discovery_packets=1, + ) + # Normally tests would simulate connection as per below + ft = FakeDatagramTransport(dp, 9999, 1, unsupported=True) + dp.connection_made(ft) + + with pytest.raises(asyncio.TimeoutError): + async with asyncio_timeout(0): + await dp.wait_for_discovery_to_complete() + + +@pytest.mark.xdist_group(name="caplog") +async def test_discovery_redaction(discovery_mock, caplog: pytest.LogCaptureFixture): + """Test query sensitive info redaction.""" + mac = "12:34:56:78:9A:BC" + + if discovery_mock.default_port == 9999: + sysinfo = discovery_mock.discovery_data["system"]["get_sysinfo"] + if "mac" in sysinfo: + sysinfo["mac"] = mac + elif "mic_mac" in sysinfo: + sysinfo["mic_mac"] = mac + else: + discovery_mock.discovery_data["result"]["mac"] = mac + + # Info no message logging + caplog.set_level(logging.INFO) + await Discover.discover() + + assert mac not in caplog.text + + caplog.set_level(logging.DEBUG) + + # Debug no redaction + caplog.clear() + Discover._redact_data = False + await Discover.discover() + assert mac in caplog.text + + # Debug redaction + caplog.clear() + Discover._redact_data = True + await Discover.discover() + assert mac not in caplog.text + assert "12:34:56:00:00:00" in caplog.text + + +async def test_discovery_decryption(): + """Test discovery decryption.""" + key = b"8\x89\x02\xfa\xf5Xs\x1c\xa1 H\x9a\x82\xc7\xd9\t" + iv = b"9=\xf8\x1bS\xcd0\xb5\x89i\xba\xfd^9\x9f\xfa" + key_iv = key + iv + + _AesDiscoveryQuery.generate_query() + keypair = _AesDiscoveryQuery.keypair + + padding = asymmetric_padding.OAEP( + mgf=asymmetric_padding.MGF1(algorithm=hashes.SHA1()), # noqa: S303 + algorithm=hashes.SHA1(), # noqa: S303 + label=None, + ) + encrypted_key_iv = keypair.public_key.encrypt(key_iv, padding) + encrypted_key_iv_b4 = base64.b64encode(encrypted_key_iv) + encryption_session = AesEncyptionSession(key_iv[:16], key_iv[16:]) + + data_dict = {"foo": 1, "bar": 2} + data = json.dumps(data_dict) + encypted_data = encryption_session.encrypt(data.encode()) + + encrypt_info = { + "data": encypted_data.decode(), + "key": encrypted_key_iv_b4.decode(), + "sym_schm": "AES", + } + info = {**UNSUPPORTED["result"], "encrypt_info": encrypt_info} + dr = DiscoveryResult.from_dict(info) + Discover._decrypt_discovery_data(dr) + assert dr.decrypted_data == data_dict + + +async def test_discover_try_connect_all(discovery_mock, mocker): + """Test that device update is called on main.""" + if "result" in discovery_mock.discovery_data: + dev_class = get_device_class_from_family( + discovery_mock.device_type, https=discovery_mock.https + ) + cparams = DeviceConnectionParameters.from_values( + discovery_mock.device_type, + discovery_mock.encrypt_type, + 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) + ) + protocol_class = protocol.__class__ + transport_class = protocol._transport.__class__ + else: + dev_class = get_device_class_from_sys_info(discovery_mock.discovery_data) + 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("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("Unable to execute update") + + mocker.patch("kasa.IotProtocol.query", new=_query) + mocker.patch("kasa.SmartProtocol.query", new=_query) + mocker.patch.object(dev_class, "update", new=_update) + + session = aiohttp.ClientSession() + dev = await Discover.try_connect_all(discovery_mock.ip, http_client=session) + + assert dev + assert isinstance(dev, dev_class) + assert isinstance(dev.protocol, protocol_class) + assert isinstance(dev.protocol._transport, transport_class) + assert dev.config.uses_http is (transport_class != XorTransport) + if transport_class != XorTransport: + assert dev.protocol._transport._http_client.client == session + + +async def test_discovery_device_repr(discovery_mock, mocker): + """Test that repr works when only discovery data is available.""" + host = "foobar" + ip = "127.0.0.1" + + discovery_mock.ip = ip + device_class = Discover._get_device_class(discovery_mock.discovery_data) + update_mock = mocker.patch.object(device_class, "update") + + dev = await Discover.discover_single(host, credentials=Credentials()) + assert update_mock.call_count == 0 + + repr_ = repr(dev) + assert dev.host in repr_ + assert str(dev.device_type) in repr_ + assert dev.model in repr_ + + # For IOT devices, _last_update is filled from the discovery data + if dev._last_update: + assert "update() needed" not in repr_ + else: + assert "update() needed" in repr_ diff --git a/tests/test_feature.py b/tests/test_feature.py new file mode 100644 index 000000000..bb707688e --- /dev/null +++ b/tests/test_feature.py @@ -0,0 +1,225 @@ +import logging +from unittest.mock import AsyncMock, patch + +import pytest +from pytest_mock import MockerFixture + +from kasa import Device, Feature, KasaException +from kasa.iot import IotStrip + +_LOGGER = logging.getLogger(__name__) + + +class DummyDevice: + pass + + +@pytest.fixture +def dummy_feature() -> Feature: + # create_autospec for device slows tests way too much, so we use a dummy here + + feat = Feature( + device=DummyDevice(), # type: ignore[arg-type] + id="dummy_feature", + name="dummy_feature", + attribute_getter="dummygetter", + attribute_setter="dummysetter", + container=None, + icon="mdi:dummy", + type=Feature.Type.Switch, + unit_getter=lambda: "dummyunit", + ) + return feat + + +def test_feature_api(dummy_feature: Feature): + """Test all properties of a dummy feature.""" + assert dummy_feature.device is not None + assert dummy_feature.name == "dummy_feature" + assert dummy_feature.attribute_getter == "dummygetter" + assert dummy_feature.attribute_setter == "dummysetter" + assert dummy_feature.container is None + assert dummy_feature.icon == "mdi:dummy" + assert dummy_feature.type == Feature.Type.Switch + assert dummy_feature.unit == "dummyunit" + + +@pytest.mark.parametrize( + "read_only_type", [Feature.Type.Sensor, Feature.Type.BinarySensor] +) +def test_feature_setter_on_sensor(read_only_type): + """Test that creating a sensor feature with a setter causes an error.""" + with pytest.raises(ValueError, match="Invalid type for configurable feature"): + Feature( + device=DummyDevice(), # type: ignore[arg-type] + id="dummy_error", + name="dummy error", + attribute_getter="dummygetter", + attribute_setter="dummysetter", + type=read_only_type, + ) + + +def test_feature_value(dummy_feature: Feature): + """Verify that property gets accessed on *value* access.""" + dummy_feature.attribute_getter = "test_prop" + dummy_feature.device.test_prop = "dummy" # type: ignore[attr-defined] + assert dummy_feature.value == "dummy" + + +def test_feature_value_container(mocker, dummy_feature: Feature): + """Test that container's attribute is accessed when expected.""" + + class DummyContainer: + @property + def test_prop(self): + return "dummy" + + dummy_feature.container = DummyContainer() # type: ignore[assignment] + dummy_feature.attribute_getter = "test_prop" + + mock_dev_prop = mocker.patch.object( + dummy_feature, "test_prop", new_callable=mocker.PropertyMock, create=True + ) + + assert dummy_feature.value == "dummy" + mock_dev_prop.assert_not_called() + + +def test_feature_value_callable(dev, dummy_feature: Feature): + """Verify that callables work as *attribute_getter*.""" + dummy_feature.attribute_getter = lambda x: "dummy value" + assert dummy_feature.value == "dummy value" + + +async def test_feature_setter(dev, mocker, dummy_feature: Feature): + """Verify that *set_value* calls the defined method.""" + mock_set_dummy = mocker.patch.object( + dummy_feature.device, "set_dummy", create=True, new_callable=AsyncMock + ) + dummy_feature.attribute_setter = "set_dummy" + await dummy_feature.set_value("dummy value") + mock_set_dummy.assert_called_with("dummy value") + + +async def test_feature_setter_read_only(dummy_feature): + """Verify that read-only feature raises an exception when trying to change it.""" + dummy_feature.attribute_setter = None + with pytest.raises(ValueError, match="Tried to set read-only feature"): + await dummy_feature.set_value("value for read only feature") + + +async def test_feature_action(mocker): + """Test that setting value on button calls the setter.""" + feat = Feature( + device=DummyDevice(), # type: ignore[arg-type] + id="dummy_feature", + name="dummy_feature", + attribute_setter="call_action", + container=None, + icon="mdi:dummy", + type=Feature.Type.Action, + ) + mock_call_action = mocker.patch.object( + feat.device, "call_action", create=True, new_callable=AsyncMock + ) + assert feat.value == "" + await feat.set_value(1234) + mock_call_action.assert_called() + + +@pytest.mark.xdist_group(name="caplog") +async def test_feature_choice_list(dummy_feature, caplog, mocker: MockerFixture): + """Test the choice feature type.""" + dummy_feature.type = Feature.Type.Choice + dummy_feature.choices_getter = lambda: ["first", "second"] + + mock_setter = mocker.patch.object( + dummy_feature.device, "dummysetter", create=True, new_callable=AsyncMock + ) + await dummy_feature.set_value("first") + mock_setter.assert_called_with("first") + mock_setter.reset_mock() + + 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 + + mock_setter.assert_not_called() + + +@pytest.mark.parametrize("precision_hint", [1, 2, 3]) +async def test_precision_hint(dummy_feature, precision_hint): + """Test that precision hint works as expected.""" + dummy_value = 3.141593 + dummy_feature.type = Feature.Type.Sensor + dummy_feature.precision_hint = precision_hint + + dummy_feature.attribute_getter = lambda x: dummy_value + assert dummy_feature.value == dummy_value + assert f"{round(dummy_value, precision_hint)} dummyunit" in repr(dummy_feature) + + +async def test_feature_setters(dev: Device, mocker: MockerFixture): + """Test that all feature setters query something.""" + # setters that do not call set on the device itself. + internal_setters = {"pan_step", "tilt_step"} + + async def _test_feature(feat, query_mock): + if feat.attribute_setter is None: + return + + # 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) + elif feat.type == Feature.Type.Switch: + await feat.set_value(True) + elif feat.type == Feature.Type.Action: + await feat.set_value("dummyvalue") + elif feat.type == Feature.Type.Choice: + await feat.set_value(feat.choices[0]) + elif feat.type == Feature.Type.Unknown: + _LOGGER.warning("Feature '%s' has no type, cannot test the setter", feat) + expecting_call = False + else: + raise NotImplementedError(f"set_value not implemented for {feat.type}") + + if expecting_call: + query_mock.assert_called() + + async def _test_features(dev): + exceptions = [] + for feat in dev.features.values(): + try: + 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: + pass + except Exception as ex: + ex.add_note(f"Exception when trying to set {feat} on {dev}") + exceptions.append(ex) + + return exceptions + + exceptions = await _test_features(dev) + + for child in dev.children: + exceptions.extend(await _test_features(child)) + + if exceptions: + raise ExceptionGroup( + "Got exceptions while testing attribute_setters", exceptions + ) diff --git a/tests/test_httpclient.py b/tests/test_httpclient.py new file mode 100644 index 000000000..906b39ed9 --- /dev/null +++ b/tests/test_httpclient.py @@ -0,0 +1,99 @@ +import re + +import aiohttp +import pytest + +from kasa.deviceconfig import DeviceConfig +from kasa.exceptions import ( + KasaException, + TimeoutError, + _ConnectionError, +) +from kasa.httpclient import HttpClient + + +@pytest.mark.parametrize( + ("error", "error_raises", "error_message"), + [ + ( + aiohttp.ServerDisconnectedError(), + _ConnectionError, + "Device connection error: ", + ), + ( + aiohttp.ClientOSError(), + _ConnectionError, + "Device connection error: ", + ), + ( + aiohttp.ServerTimeoutError(), + TimeoutError, + "Unable to query the device, timed out: ", + ), + ( + TimeoutError(), + TimeoutError, + "Unable to query the device, timed out: ", + ), + (Exception(), KasaException, "Unable to query the device: "), + ( + aiohttp.ServerFingerprintMismatch(b"exp", b"got", "host", 1), + KasaException, + "Unable to query the device: ", + ), + ], + ids=( + "ServerDisconnectedError", + "ClientOSError", + "ServerTimeoutError", + "TimeoutError", + "Exception", + "ServerFingerprintMismatch", + ), +) +@pytest.mark.parametrize("mock_read", [False, True], ids=("post", "read")) +async def test_httpclient_errors(mocker, error, error_raises, error_message, mock_read): + class _mock_response: + def __init__(self, status, error): + self.status = status + self.error = error + self.call_count = 0 + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_t, exc_v, exc_tb): + pass + + async def read(self): + self.call_count += 1 + raise self.error + + mock_response = _mock_response(200, error) + + async def _post(url, *_, **__): + nonlocal mock_response + return mock_response + + host = "127.0.0.1" + + side_effect = _post if mock_read else error + + conn = mocker.patch.object(aiohttp.ClientSession, "post", side_effect=side_effect) + client = HttpClient(DeviceConfig(host)) + # Exceptions with parameters print with double quotes, without use single quotes + full_msg = ( + re.escape("(") + + "['\"]" + + re.escape(f"{error_message}{host}: {error}") + + "['\"]" + + re.escape(f", {repr(error)})") + ) + with pytest.raises(error_raises, match=error_message) as exc_info: + await client.post("http://foobar") + + assert re.match(full_msg, str(exc_info.value)) + if mock_read: + assert mock_response.call_count == 1 + else: + assert conn.call_count == 1 diff --git a/tests/test_plug.py b/tests/test_plug.py new file mode 100644 index 000000000..25be910bd --- /dev/null +++ b/tests/test_plug.py @@ -0,0 +1,85 @@ +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 + +# these schemas should go to the mainlib as +# they can be useful when adding support for new features/devices +# as well as to check that faked devices are operating properly. + + +@plug_iot +async def test_plug_sysinfo(dev): + assert dev.sys_info is not None + SYSINFO_SCHEMA(dev.sys_info) + + assert dev.model is not None + + assert dev.device_type == DeviceType.Plug or dev.device_type == DeviceType.Strip + + +@wallswitch_iot +async def test_switch_sysinfo(dev): + assert dev.sys_info is not None + SYSINFO_SCHEMA(dev.sys_info) + + assert dev.model is not None + + assert dev.device_type == DeviceType.WallSwitch + + +@plug_iot +async def test_plug_led(dev): + with pytest.deprecated_call(match="use: Module.Led in device.modules instead"): + original = dev.led + + await dev.set_led(False) + await dev.update() + assert not dev.led + + await dev.set_led(True) + await dev.update() + assert dev.led + + await dev.set_led(original) + + +@wallswitch_iot +async def test_switch_led(dev): + with pytest.deprecated_call(match="use: Module.Led in device.modules instead"): + original = dev.led + + await dev.set_led(False) + await dev.update() + assert not dev.led + + await dev.set_led(True) + await dev.update() + assert dev.led + + await dev.set_led(original) + + +@plug_smart +async def test_plug_device_info(dev): + assert dev._info is not None + assert dev.model is not None + + assert dev.device_type == DeviceType.Plug or dev.device_type == DeviceType.Strip + + +@switch_smart +async def test_switch_device_info(dev): + assert dev._info is not None + assert dev.model is not None + + assert ( + dev.device_type == DeviceType.WallSwitch or dev.device_type == DeviceType.Dimmer + ) + + +@plug +def test_device_type_plug(dev): + assert dev.device_type == DeviceType.Plug diff --git a/tests/test_readme_examples.py b/tests/test_readme_examples.py new file mode 100644 index 000000000..2431127c7 --- /dev/null +++ b/tests/test_readme_examples.py @@ -0,0 +1,202 @@ +import asyncio + +import pytest +import xdoctest + +from .conftest import ( + get_device_for_fixture_protocol, + get_fixture_info, + patch_discovery, +) + + +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")) + 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_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") + assert not res["failed"] + + +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")) + 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(readmes_mock): + """Test strip examples.""" + res = xdoctest.doctest_module("kasa.iot.iotstrip", "all") + assert not res["failed"] + + +def test_dimmer_examples(mocker): + """Test dimmer examples.""" + p = asyncio.run(get_device_for_fixture_protocol("HS220(US)_1.0_1.5.7.json", "IOT")) + mocker.patch("kasa.iot.iotdimmer.IotDimmer", return_value=p) + mocker.patch("kasa.iot.iotdimmer.IotDimmer.update") + res = xdoctest.doctest_module("kasa.iot.iotdimmer", "all") + assert not res["failed"] + + +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") + assert not res["failed"] + + +def test_discovery_examples(readmes_mock): + """Test discovery examples.""" + res = xdoctest.doctest_module("kasa.discover", "all") + assert res["n_passed"] > 0 + assert res["n_warned"] == 0 + assert not res["failed"] + + +def test_deviceconfig_examples(readmes_mock): + """Test discovery examples.""" + res = xdoctest.doctest_module("kasa.deviceconfig", "all") + assert res["n_passed"] > 0 + assert res["n_warned"] == 0 + assert not res["failed"] + + +def test_device_examples(readmes_mock): + """Test device examples.""" + res = xdoctest.doctest_module("kasa.device", "all") + assert res["n_passed"] > 0 + assert res["n_warned"] == 0 + assert not res["failed"] + + +def test_light_examples(readmes_mock): + """Test device examples.""" + res = xdoctest.doctest_module("kasa.interfaces.light", "all") + assert res["n_passed"] > 0 + assert res["n_warned"] == 0 + assert not res["failed"] + + +def test_light_preset_examples(readmes_mock): + """Test device examples.""" + res = xdoctest.doctest_module("kasa.interfaces.lightpreset", "all") + assert res["n_passed"] > 0 + assert res["n_warned"] == 0 + assert not res["failed"] + + +def test_light_effect_examples(readmes_mock): + """Test device examples.""" + res = xdoctest.doctest_module("kasa.interfaces.lighteffect", "all") + assert res["n_passed"] > 0 + assert res["n_warned"] == 0 + assert not res["failed"] + + +def test_child_examples(readmes_mock): + """Test device examples.""" + res = xdoctest.doctest_module("kasa.smart.modules.childdevice", "all") + assert res["n_passed"] > 0 + assert res["n_warned"] == 0 + assert not res["failed"] + + +def test_module_examples(readmes_mock): + """Test device examples.""" + res = xdoctest.doctest_module("kasa.module", "all") + assert res["n_passed"] > 0 + assert res["n_warned"] == 0 + assert not res["failed"] + + +def test_feature_examples(readmes_mock): + """Test device examples.""" + res = xdoctest.doctest_module("kasa.feature", "all") + assert res["n_passed"] > 0 + assert res["n_warned"] == 0 + assert not res["failed"] + + +def test_tutorial_examples(readmes_mock): + """Test discovery examples.""" + res = xdoctest.doctest_module("docs/tutorial.py", "all") + assert res["n_passed"] > 0 + assert res["n_warned"] == 0 + 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 = { + "127.0.0.1": get_fixture_info("KP303(UK)_1.0_1.0.3.json", "IOT"), # Strip + "127.0.0.2": get_fixture_info("HS110(EU)_1.0_1.2.5.json", "IOT"), # Plug + "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/kasa/tests/test_strip.py b/tests/test_strip.py similarity index 64% rename from kasa/tests/test_strip.py rename to tests/test_strip.py index 861b56ed3..73d10bb7d 100644 --- a/kasa/tests/test_strip.py +++ b/tests/test_strip.py @@ -2,9 +2,10 @@ import pytest -from kasa import SmartDeviceException, SmartStrip +from kasa import Device, KasaException, Module +from kasa.iot import IotStrip -from .conftest import handle_turn_on, pytestmark, strip, turn_on +from .conftest import handle_turn_on, strip, strip_iot, turn_on @strip @@ -15,20 +16,24 @@ async def test_children_change_state(dev, turn_on): orig_state = plug.is_on if orig_state: await plug.turn_off() - assert not plug.is_on - assert plug.is_off + await dev.update() + assert plug.is_on is False + assert plug.is_off is True await plug.turn_on() - assert plug.is_on - assert not plug.is_off + await dev.update() + assert plug.is_on is True + assert plug.is_off is False else: await plug.turn_on() - assert plug.is_on - assert not plug.is_off + await dev.update() + assert plug.is_on is True + assert plug.is_off is False await plug.turn_off() - assert not plug.is_on - assert plug.is_off + await dev.update() + assert plug.is_on is False + assert plug.is_off is True @strip @@ -64,25 +69,33 @@ async def test_children_on_since(dev): @strip -async def test_get_plug_by_name(dev: SmartStrip): +async def test_get_plug_by_name(dev: IotStrip): name = dev.children[0].alias - assert dev.get_plug_by_name(name) == dev.children[0] + assert dev.get_plug_by_name(name) == dev.children[0] # type: ignore[arg-type] - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): dev.get_plug_by_name("NONEXISTING NAME") @strip -async def test_get_plug_by_index(dev: SmartStrip): +async def test_get_plug_by_index(dev: IotStrip): assert dev.get_plug_by_index(0) == dev.children[0] - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): dev.get_plug_by_index(-1) - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): dev.get_plug_by_index(len(dev.children)) +@strip +async def test_plug_features(dev: IotStrip): + """Test the child plugs have default features.""" + for child in dev.children: + assert "state" in child.features + assert "on_since" in child.features + + @pytest.mark.skip("this test will wear out your relays") async def test_all_binary_states(dev): # test every binary state @@ -104,7 +117,6 @@ async def test_all_binary_states(dev): # toggle each outlet with state map applied for plug_index in range(len(dev.children)): - # toggle state if state_map[plug_index]: await dev.turn_off(index=plug_index) @@ -127,3 +139,24 @@ async def test_all_binary_states(dev): # original state map should be restored for index, state in dev.is_on.items(): assert state == state_map[index] + + +@strip +def test_children_api(dev): + """Test the child device API.""" + first = dev.children[0] + first_by_get_child_device = dev.get_child_device(first.device_id) + assert first == first_by_get_child_device + + +@strip_iot +async def test_children_energy(dev: Device): + if Module.Energy not in dev.modules: + pytest.skip(f"skipping device {dev.model} does not support energy") + + for plug in dev.children: + # For now all known strips with energy support these + energy = plug.modules[Module.Energy] + assert "voltage" in energy._module_features + assert "current" in energy._module_features + assert "current_consumption" in energy._module_features diff --git a/tests/transports/__init__.py b/tests/transports/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/transports/test_aestransport.py b/tests/transports/test_aestransport.py new file mode 100644 index 000000000..793352965 --- /dev/null +++ b/tests/transports/test_aestransport.py @@ -0,0 +1,541 @@ +from __future__ import annotations + +import base64 +import json +import logging +import random +import string +import time +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 cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import padding as asymmetric_padding +from freezegun.api import FrozenDateTimeFactory +from yarl import URL + +from kasa.credentials import Credentials +from kasa.deviceconfig import DeviceConfig +from kasa.exceptions import ( + AuthenticationError, + KasaException, + SmartErrorCode, + _ConnectionError, +) +from kasa.httpclient import HttpClient +from kasa.transports.aestransport import ( + AesEncyptionSession, + AesTransport, + TransportState, +) + +pytestmark = [pytest.mark.requires_dummy] + +DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}} + +key = b"8\x89\x02\xfa\xf5Xs\x1c\xa1 H\x9a\x82\xc7\xd9\t" +iv = b"9=\xf8\x1bS\xcd0\xb5\x89i\xba\xfd^9\x9f\xfa" +KEY_IV = key + iv + + +def test_encrypt(): + encryption_session = AesEncyptionSession(KEY_IV[:16], KEY_IV[16:]) + + d = json.dumps({"foo": 1, "bar": 2}) + encrypted = encryption_session.encrypt(d.encode()) + assert d == encryption_session.decrypt(encrypted) + + # test encrypt unicode + d = "{'snowman': '\u2603'}" + encrypted = encryption_session.encrypt(d.encode()) + assert d == encryption_session.decrypt(encrypted) + + +status_parameters = pytest.mark.parametrize( + ("status_code", "error_code", "inner_error_code", "expectation"), + [ + (200, 0, 0, does_not_raise()), + (400, 0, 0, pytest.raises(KasaException)), + (200, -1, 0, pytest.raises(KasaException)), + ], + ids=("success", "status_code", "error_code"), +) + + +@status_parameters +async def test_handshake( + mocker, status_code, error_code, inner_error_code, expectation +): + 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) + + transport = AesTransport( + config=DeviceConfig(host, credentials=Credentials("foo", "bar")) + ) + + 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.LOGIN_REQUIRED + + +async def test_handshake_with_keys(mocker): + host = "127.0.0.1" + mock_aes_device = MockAesDevice(host) + mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post) + + test_keys = { + "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", + } + transport = AesTransport( + config=DeviceConfig( + host, credentials=Credentials("foo", "bar"), aes_keys=test_keys + ) + ) + + assert transport._encryption_session is None + assert transport._state is TransportState.HANDSHAKE_REQUIRED + + await transport.perform_handshake() + 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): + 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) + + transport = AesTransport( + config=DeviceConfig(host, credentials=Credentials("foo", "bar")) + ) + transport._state = TransportState.LOGIN_REQUIRED + transport._session_expire_at = time.time() + 86400 + transport._encryption_session = mock_aes_device.encryption_session + + assert transport._token_url is None + with expectation: + await transport.perform_login() + assert mock_aes_device.token in str(transport._token_url) + assert transport._config.aes_keys == transport._key_pair + + +@pytest.mark.parametrize( + ("inner_error_codes", "expectation", "call_count"), + [ + ([SmartErrorCode.LOGIN_ERROR, 0, 0, 0], does_not_raise(), 4), + ( + [SmartErrorCode.LOGIN_ERROR, SmartErrorCode.LOGIN_ERROR], + pytest.raises(AuthenticationError), + 3, + ), + ( + [SmartErrorCode.LOGIN_FAILED_ERROR], + pytest.raises(AuthenticationError), + 1, + ), + ( + [SmartErrorCode.LOGIN_ERROR, SmartErrorCode.SESSION_TIMEOUT_ERROR], + pytest.raises(KasaException), + 3, + ), + ], + ids=( + "LOGIN_ERROR-success", + "LOGIN_ERROR-LOGIN_ERROR", + "LOGIN_FAILED_ERROR", + "LOGIN_ERROR-SESSION_TIMEOUT_ERROR", + ), +) +async def test_login_errors(mocker, inner_error_codes, expectation, call_count): + host = "127.0.0.1" + mock_aes_device = MockAesDevice(host, 200, 0, inner_error_codes) + post_mock = mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_aes_device.post + ) + + transport = AesTransport( + config=DeviceConfig(host, credentials=Credentials("foo", "bar")) + ) + transport._state = TransportState.LOGIN_REQUIRED + transport._session_expire_at = time.time() + 86400 + transport._encryption_session = mock_aes_device.encryption_session + mocker.patch.object(transport._http_client, "WAIT_BETWEEN_REQUESTS_ON_OSERROR", 0) + + assert transport._token_url is None + + request = { + "method": "get_device_info", + "params": None, + "request_time_milis": round(time.time() * 1000), + "requestID": 1, + "terminal_uuid": "foobar", + } + + with expectation: + await transport.send(json_dumps(request)) + assert mock_aes_device.token in str(transport._token_url) + assert post_mock.call_count == call_count # Login, Handshake, Login + await transport.close() + + +@status_parameters +async def test_send(mocker, status_code, error_code, inner_error_code, expectation): + 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) + + transport = AesTransport( + config=DeviceConfig(host, credentials=Credentials("foo", "bar")) + ) + transport._handshake_done = True + transport._session_expire_at = time.time() + 86400 + transport._encryption_session = mock_aes_device.encryption_session + transport._token_url = transport._app_url.with_query( + f"token={mock_aes_device.token}" + ) + + request = { + "method": "get_device_info", + "params": None, + "request_time_milis": round(time.time() * 1000), + "requestID": 1, + "terminal_uuid": "foobar", + } + with expectation: + 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_aes_device = MockAesDevice(host, 200, 0, 0, do_not_encrypt_response=True) + mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post) + + transport = AesTransport( + config=DeviceConfig(host, credentials=Credentials("foo", "bar")) + ) + 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( + f"token={mock_aes_device.token}" + ) + + request = { + "method": "get_device_info", + "params": None, + "request_time_milis": round(time.time() * 1000), + "requestID": 1, + "terminal_uuid": "foobar", + } + 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_unencrypted_response_invalid_json(mocker, caplog): + host = "127.0.0.1" + mock_aes_device = MockAesDevice( + host, 200, 0, 0, do_not_encrypt_response=True, send_response=b"Foobar" + ) + mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post) + + transport = AesTransport( + config=DeviceConfig(host, credentials=Credentials("foo", "bar")) + ) + 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( + f"token={mock_aes_device.token}" + ) + + request = { + "method": "get_device_info", + "params": None, + "request_time_milis": round(time.time() * 1000), + "requestID": 1, + "terminal_uuid": "foobar", + } + caplog.set_level(logging.DEBUG) + msg = f"Unable to decrypt response from {host}, error: Incorrect padding, response: Foobar" + with pytest.raises(KasaException, match=msg): + await transport.send(json_dumps(request)) + + +ERRORS = [e for e in SmartErrorCode if e != 0] + + +@pytest.mark.parametrize("error_code", ERRORS, ids=lambda e: e.name) +async def test_passthrough_errors(mocker, error_code): + 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._session_expire_at = time.time() + 86400 + transport._encryption_session = mock_aes_device.encryption_session + transport._token_url = transport._app_url.with_query( + f"token={mock_aes_device.token}" + ) + + request = { + "method": "get_device_info", + "params": None, + "request_time_milis": round(time.time() * 1000), + "requestID": 1, + "terminal_uuid": "foobar", + } + with pytest.raises(KasaException): + await transport.send(json_dumps(request)) + + +@pytest.mark.parametrize("error_code", [-13333, 13333]) +async def test_unknown_errors(mocker, error_code): + 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._session_expire_at = time.time() + 86400 + transport._encryption_session = mock_aes_device.encryption_session + transport._token_url = transport._app_url.with_query( + f"token={mock_aes_device.token}" + ) + + request = { + "method": "get_device_info", + "params": None, + "request_time_milis": round(time.time() * 1000), + "requestID": 1, + "terminal_uuid": "foobar", + } + with pytest.raises(KasaException): # noqa: PT012 + res = await transport.send(json_dumps(request)) + assert res is SmartErrorCode.INTERNAL_UNKNOWN_ERROR + + +async def test_port_override(): + """Test that port override sets the app_url.""" + host = "127.0.0.1" + config = DeviceConfig( + host, credentials=Credentials("foo", "bar"), port_override=12345 + ) + transport = AesTransport(config=config) + + assert str(transport._app_url) == "http://127.0.0.1:12345/app" + + +@pytest.mark.parametrize( + ("device_delay_required", "should_error", "should_succeed"), + [ + pytest.param(0, False, True, id="No error"), + pytest.param(0.125, True, True, id="Error then succeed"), + pytest.param(0.3, True, True, id="Two errors then succeed"), + pytest.param(0.7, True, False, id="No succeed"), + ], +) +async def test_device_closes_connection( + mocker, + freezer: FrozenDateTimeFactory, + device_delay_required, + should_error, + should_succeed, +): + """Test the delay logic in http client to deal with devices that close connections after each request. + + Currently only the P100 on older firmware. + """ + host = "127.0.0.1" + + default_delay = HttpClient.WAIT_BETWEEN_REQUESTS_ON_OSERROR + + mock_aes_device = MockAesDevice( + host, 200, 0, 0, sequential_request_delay=device_delay_required + ) + mocker.patch.object(aiohttp.ClientSession, "post", side_effect=mock_aes_device.post) + + async def _asyncio_sleep_mock(delay, result=None): + freezer.tick(delay) + return result + + mocker.patch("asyncio.sleep", side_effect=_asyncio_sleep_mock) + + config = DeviceConfig(host, credentials=Credentials("foo", "bar")) + transport = AesTransport(config=config) + transport._http_client.WAIT_BETWEEN_REQUESTS_ON_OSERROR = default_delay + transport._state = TransportState.LOGIN_REQUIRED + transport._session_expire_at = time.time() + 86400 + transport._encryption_session = mock_aes_device.encryption_session + transport._token_url = transport._app_url.with_query( + f"token={mock_aes_device.token}" + ) + request = { + "method": "get_device_info", + "params": None, + "request_time_milis": round(time.time() * 1000), + "requestID": 1, + "terminal_uuid": "foobar", + } + error_count = 0 + success = False + + # If the device errors without a delay then it should error immedately ( + 1) + # and then the number of times the default delay passes within the request delay window + expected_error_count = ( + 0 if not should_error else int(device_delay_required / default_delay) + 1 + ) + for _ in range(3): + try: + await transport.send(json_dumps(request)) + except _ConnectionError: + error_count += 1 + else: + success = True + + assert bool(transport._http_client._wait_between_requests) == should_error + assert bool(error_count) == should_error + assert error_count == expected_error_count + assert success == should_succeed + + +class MockAesDevice: + class _mock_response: + def __init__(self, status, json: dict): + self.status = status + self._json = json + + 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 + + encryption_session = AesEncyptionSession(KEY_IV[:16], KEY_IV[16:]) + + def __init__( + self, + host, + status_code=200, + error_code=0, + inner_error_code=0, + *, + do_not_encrypt_response=False, + send_response=None, + sequential_request_delay=0, + ): + self.host = host + self.status_code = status_code + self.error_code = error_code + self._inner_error_code = inner_error_code + self.do_not_encrypt_response = do_not_encrypt_response + self.send_response = send_response + self.http_client = HttpClient(DeviceConfig(self.host)) + self.inner_call_count = 0 + self.token = "".join(random.choices(string.ascii_uppercase, k=32)) # noqa: S311 + self.sequential_request_delay = sequential_request_delay + self.last_request_time = None + self.sequential_error_raised = False + + @property + def inner_error_code(self): + if isinstance(self._inner_error_code, list): + return self._inner_error_code[self.inner_call_count] + else: + return self._inner_error_code + + async def post(self, url: URL, params=None, json=None, data=None, *_, **__): + if self.sequential_request_delay and self.last_request_time: + now = time.time() + print(now - self.last_request_time) + if (now - self.last_request_time) < self.sequential_request_delay: + self.sequential_error_raised = True + raise aiohttp.ClientOSError("Test connection closed") + if data: + async for item in data: + json = json_loads(item.decode()) + res = await self._post(url, json) + if self.sequential_request_delay: + self.last_request_time = time.time() + return res + + async def _post(self, url: URL, json: dict[str, Any]): + if json["method"] == "handshake": + return await self._return_handshake_response(url, json) + elif json["method"] == "securePassthrough": + return await self._return_secure_passthrough_response(url, json) + elif json["method"] == "login_device": + return await self._return_login_response(url, json) + else: + assert url == URL(f"http://{self.host}:80/app?token={self.token}") + return await self._return_send_response(url, json) + + async def _return_handshake_response(self, url: URL, json: dict[str, Any]): + start = len("-----BEGIN PUBLIC KEY-----\n") + end = len("\n-----END PUBLIC KEY-----\n") + client_pub_key = json["params"]["key"][start:-end] + + client_pub_key_data = base64.b64decode(client_pub_key.encode()) + client_pub_key = serialization.load_der_public_key(client_pub_key_data, None) + encrypted_key = client_pub_key.encrypt(KEY_IV, asymmetric_padding.PKCS1v15()) + key_64 = base64.b64encode(encrypted_key).decode() + return self._mock_response( + self.status_code, {"result": {"key": key_64}, "error_code": self.error_code} + ) + + async def _return_secure_passthrough_response(self, url: URL, json: dict[str, Any]): + encrypted_request = json["params"]["request"] + 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.error_code, + } + return self._mock_response(self.status_code, result) + + async def _return_login_response(self, url: URL, json: dict[str, Any]): + if "token=" in str(url): + raise Exception("token should not be in url for a login request") + self.token = "".join(random.choices(string.ascii_uppercase, k=32)) # noqa: S311 + result = {"result": {"token": self.token}, "error_code": self.inner_error_code} + self.inner_call_count += 1 + 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.inner_error_code} + response = self.send_response if self.send_response else result + self.inner_call_count += 1 + return self._mock_response(self.status_code, response) diff --git a/tests/transports/test_klaptransport.py b/tests/transports/test_klaptransport.py new file mode 100644 index 000000000..26d9f57a4 --- /dev/null +++ b/tests/transports/test_klaptransport.py @@ -0,0 +1,565 @@ +import json +import logging +import re +import secrets +import time +from contextlib import nullcontext as does_not_raise + +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, + TimeoutError, + _ConnectionError, + _RetryableError, +) +from kasa.httpclient import HttpClient +from kasa.protocols import IotProtocol, SmartProtocol +from kasa.transports.aestransport import AesTransport +from kasa.transports.klaptransport import ( + KlapEncryptionSession, + KlapTransport, + KlapTransportV2, + _sha256, +) + +DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}} + +# Transport tests are not designed for real devices +pytestmark = [pytest.mark.requires_dummy] + + +class _mock_response: + def __init__(self, status, content: bytes): + self.status = status + self.content = content + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_t, exc_v, exc_tb): + pass + + async def read(self): + return self.content + + +@pytest.mark.parametrize( + ("error", "retry_expectation"), + [ + (Exception("dummy exception"), False), + (aiohttp.ServerTimeoutError("dummy exception"), True), + (aiohttp.ServerDisconnectedError("dummy exception"), True), + (aiohttp.ClientOSError("dummy exception"), True), + ], + ids=("Exception", "ServerTimeoutError", "ServerDisconnectedError", "ClientOSError"), +) +@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_retries_via_client_session( + mocker, retry_count, protocol_class, transport_class, error, retry_expectation +): + host = "127.0.0.1" + conn = mocker.patch.object(aiohttp.ClientSession, "post", side_effect=error) + + config = DeviceConfig(host) + with pytest.raises(KasaException): + await protocol_class(transport=transport_class(config=config)).query( + DUMMY_QUERY, retry_count=retry_count + ) + + expected_count = retry_count + 1 if retry_expectation else 1 + assert conn.call_count == expected_count + + +@pytest.mark.parametrize( + ("error", "retry_expectation"), + [ + (KasaException("dummy exception"), False), + (_RetryableError("dummy exception"), True), + (TimeoutError("dummy exception"), True), + ], + ids=("KasaException", "_RetryableError", "TimeoutError"), +) +@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_retries_via_httpclient( + mocker, retry_count, protocol_class, transport_class, error, retry_expectation +): + host = "127.0.0.1" + conn = mocker.patch.object(HttpClient, "post", side_effect=error) + mocker.patch.object(protocol_class, "BACKOFF_SECONDS_AFTER_TIMEOUT", 0) + + config = DeviceConfig(host) + with pytest.raises(KasaException): + await protocol_class(transport=transport_class(config=config)).query( + DUMMY_QUERY, retry_count=retry_count + ) + + expected_count = retry_count + 1 if retry_expectation else 1 + assert conn.call_count == expected_count + + +@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 +): + host = "127.0.0.1" + conn = mocker.patch.object( + aiohttp.ClientSession, + "post", + side_effect=AuthenticationError("foo"), + ) + mocker.patch.object(protocol_class, "BACKOFF_SECONDS_AFTER_TIMEOUT", 0) + config = DeviceConfig(host) + with pytest.raises(KasaException): + await protocol_class(transport=transport_class(config=config)).query( + DUMMY_QUERY, retry_count=5 + ) + + assert conn.call_count == 1 + + +@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 +): + host = "127.0.0.1" + conn = mocker.patch.object( + aiohttp.ClientSession, + "post", + side_effect=aiohttp.ClientOSError("foo"), + ) + + config = DeviceConfig(host) + with pytest.raises(KasaException): + await protocol_class(transport=transport_class(config=config)).query( + DUMMY_QUERY, retry_count=5 + ) + + assert conn.call_count == 6 + + +@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): + host = "127.0.0.1" + remaining = retry_count + mock_response = {"result": {"great": "success"}, "error_code": 0} + + def _fail_one_less_than_retry_count(*_, **__): + nonlocal remaining + remaining -= 1 + if remaining: + raise _ConnectionError("Simulated connection failure") + + return mock_response + + mocker.patch.object(transport_class, "perform_handshake") + if hasattr(transport_class, "perform_login"): + mocker.patch.object(transport_class, "perform_login") + + send_mock = mocker.patch.object( + transport_class, + "send", + side_effect=_fail_one_less_than_retry_count, + ) + + config = DeviceConfig(host) + response = await protocol_class(transport=transport_class(config=config)).query( + DUMMY_QUERY, retry_count=retry_count + ) + assert "result" in response or "foobar" in response + assert send_mock.call_count == 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): + 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 + + seed = secrets.token_bytes(16) + auth_hash = KlapTransport.generate_auth_hash(Credentials("foo", "bar")) + encryption_session = KlapEncryptionSession(seed, seed, auth_hash) + + config = DeviceConfig("127.0.0.1") + protocol = IotProtocol(transport=KlapTransport(config=config)) + + protocol._transport._handshake_done = True + protocol._transport._session_expire_at = time.time() + 86400 + protocol._transport._encryption_session = encryption_session + mocker.patch.object(HttpClient, "post", side_effect=_return_encrypted) + + response = await protocol.query({}) + assert response == {"great": "success"} + if log_level == logging.DEBUG: + assert "success" in caplog.text + else: + assert "success" not in caplog.text + + +def test_encrypt(): + d = json.dumps({"foo": 1, "bar": 2}) + + seed = secrets.token_bytes(16) + auth_hash = KlapTransport.generate_auth_hash(Credentials("foo", "bar")) + encryption_session = KlapEncryptionSession(seed, seed, auth_hash) + + encrypted, seq = encryption_session.encrypt(d) + + assert d == encryption_session.decrypt(encrypted) + + +def test_encrypt_unicode(): + d = "{'snowman': '\u2603'}" + + seed = secrets.token_bytes(16) + auth_hash = KlapTransport.generate_auth_hash(Credentials("foo", "bar")) + encryption_session = KlapEncryptionSession(seed, seed, auth_hash) + + encrypted, seq = encryption_session.encrypt(d) + + decrypted = encryption_session.decrypt(encrypted) + + assert d == decrypted + + +async def test_transport_decrypt(mocker): + """Test transport decryption.""" + d = {"great": "success"} + + seed = secrets.token_bytes(16) + auth_hash = KlapTransport.generate_auth_hash(Credentials("foo", "bar")) + encryption_session = KlapEncryptionSession(seed, seed, auth_hash) + + transport = KlapTransport(config=DeviceConfig(host="127.0.0.1")) + transport._handshake_done = True + transport._session_expire_at = time.monotonic() + 60 + transport._encryption_session = encryption_session + + async def _return_response(url: URL, params=None, data=None, *_, **__): + encryption_session = KlapEncryptionSession( + transport._encryption_session.local_seed, + transport._encryption_session.remote_seed, + transport._encryption_session.user_hash, + ) + seq = params.get("seq") + encryption_session._seq = seq - 1 + encrypted, seq = encryption_session.encrypt(json.dumps(d)) + seq = seq + return 200, encrypted + + mocker.patch.object(HttpClient, "post", side_effect=_return_response) + + resp = await transport.send(json.dumps({})) + assert d == resp + + +async def test_transport_decrypt_error(mocker, caplog): + """Test that a decryption error raises a kasa exception.""" + d = {"great": "success"} + + seed = secrets.token_bytes(16) + auth_hash = KlapTransport.generate_auth_hash(Credentials("foo", "bar")) + encryption_session = KlapEncryptionSession(seed, seed, auth_hash) + + transport = KlapTransport(config=DeviceConfig(host="127.0.0.1")) + transport._handshake_done = True + transport._session_expire_at = time.monotonic() + 60 + transport._encryption_session = encryption_session + + async def _return_response(url: URL, params=None, data=None, *_, **__): + encryption_session = KlapEncryptionSession( + secrets.token_bytes(16), + transport._encryption_session.remote_seed, + transport._encryption_session.user_hash, + ) + seq = params.get("seq") + encryption_session._seq = seq - 1 + encrypted, seq = encryption_session.encrypt(json.dumps(d)) + seq = seq + return 200, encrypted + + mocker.patch.object(HttpClient, "post", side_effect=_return_response) + + with pytest.raises( + KasaException, + match=re.escape("Error trying to decrypt device 127.0.0.1 response:"), + ): + await transport.send(json.dumps({})) + + +@pytest.mark.parametrize( + ("device_credentials", "expectation"), + [ + (Credentials("foo", "bar"), does_not_raise()), + (Credentials(), does_not_raise()), + ( + get_default_credentials(DEFAULT_CREDENTIALS["KASA"]), + does_not_raise(), + ), + ( + Credentials("shouldfail", "shouldfail"), + pytest.raises(AuthenticationError), + ), + ], + ids=("client", "blank", "kasa_setup", "shouldfail"), +) +@pytest.mark.parametrize( + ("transport_class", "seed_auth_hash_calc"), + [ + pytest.param(KlapTransport, lambda c, s, a: c + a, id="KLAP"), + pytest.param(KlapTransportV2, lambda c, s, a: c + s + a, id="KLAPV2"), + ], +) +async def test_handshake1( + mocker, device_credentials, expectation, transport_class, seed_auth_hash_calc +): + async def _return_handshake1_response(url, params=None, data=None, *_, **__): + nonlocal client_seed, server_seed, device_auth_hash + + client_seed = data + seed_auth_hash = _sha256( + seed_auth_hash_calc(client_seed, server_seed, device_auth_hash) + ) + 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)) + + with expectation: + ( + local_seed, + device_remote_seed, + auth_hash, + ) = await protocol._transport.perform_handshake1() + + assert local_seed == client_seed + assert device_remote_seed == server_seed + assert device_auth_hash == auth_hash + await protocol.close() + + +@pytest.mark.parametrize( + ("transport_class", "seed_auth_hash_calc1", "seed_auth_hash_calc2"), + [ + pytest.param( + KlapTransport, lambda c, s, a: c + a, lambda c, s, a: s + a, id="KLAP" + ), + pytest.param( + KlapTransportV2, + lambda c, s, a: c + s + a, + lambda c, s, a: s + c + a, + id="KLAPV2", + ), + ], +) +async def test_handshake( + mocker, transport_class, seed_auth_hash_calc1, seed_auth_hash_calc2 +): + 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 + + if url == URL("http://127.0.0.1:80/app/handshake1"): + client_seed = data + seed_auth_hash = _sha256( + seed_auth_hash_calc1(client_seed, server_seed, device_auth_hash) + ) + + return _mock_response(200, server_seed + seed_auth_hash) + elif url == URL("http://127.0.0.1:80/app/handshake2"): + seed_auth_hash = _sha256( + seed_auth_hash_calc2(client_seed, server_seed, device_auth_hash) + ) + assert data == seed_auth_hash + return _mock_response(response_status, b"") + + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=_return_handshake_response + ) + + config = DeviceConfig("127.0.0.1", credentials=client_credentials) + protocol = IotProtocol(transport=transport_class(config=config)) + protocol._transport.http_client = aiohttp.ClientSession() + + response_status = 200 + await protocol._transport.perform_handshake() + assert protocol._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 protocol.close() + + +async def test_query(mocker): + client_seed = None + last_seq = None + seq = None + server_seed = secrets.token_bytes(16) + client_credentials = Credentials("foo", "bar") + 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 + + if url == URL("http://127.0.0.1:80/app/handshake1"): + client_seed = data + client_seed_auth_hash = _sha256(data + device_auth_hash) + + return _mock_response(200, server_seed + client_seed_auth_hash) + 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"): + encryption_session = KlapEncryptionSession( + protocol._transport._encryption_session.local_seed, + protocol._transport._encryption_session.remote_seed, + protocol._transport._encryption_session.user_hash, + ) + seq = params.get("seq") + encryption_session._seq = seq - 1 + encrypted, seq = encryption_session.encrypt('{"great": "success"}') + seq = seq + return _mock_response(200, encrypted) + + 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)) + + for _ in range(10): + resp = await protocol.query({}) + assert resp == {"great": "success"} + # Check the protocol is incrementing the sequence number + assert last_seq is None or last_seq + 1 == seq + last_seq = seq + + +@pytest.mark.parametrize( + ("response_status", "credentials_match", "expectation"), + [ + pytest.param( + (403, 403, 403), + True, + pytest.raises(KasaException), + id="handshake1-403-status", + ), + pytest.param( + (200, 403, 403), + True, + pytest.raises(KasaException), + id="handshake2-403-status", + ), + pytest.param( + (200, 200, 403), + True, + pytest.raises(_RetryableError), + id="request-403-status", + ), + pytest.param( + (200, 200, 400), + True, + pytest.raises(KasaException), + id="request-400-status", + ), + pytest.param( + (200, 200, 200), + False, + pytest.raises(AuthenticationError), + id="handshake1-wrong-auth", + ), + pytest.param( + (200, 200, 200), + secrets.token_bytes(16), + pytest.raises(KasaException), + id="handshake1-bad-auth-length", + ), + ], +) +async def test_authentication_failures( + mocker, response_status, credentials_match, expectation +): + client_seed = None + + server_seed = secrets.token_bytes(16) + client_credentials = Credentials("foo", "bar") + device_credentials = ( + client_credentials if credentials_match else Credentials("bar", "foo") + ) + 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 + + if url == URL("http://127.0.0.1:80/app/handshake1"): + client_seed = data + client_seed_auth_hash = _sha256(data + device_auth_hash) + if credentials_match is not False and credentials_match is not True: + client_seed_auth_hash += credentials_match + return _mock_response( + response_status[0], server_seed + client_seed_auth_hash + ) + elif url == URL("http://127.0.0.1:80/app/handshake2"): + client_seed = data + client_seed_auth_hash = _sha256(data + device_auth_hash) + return _mock_response( + response_status[1], server_seed + client_seed_auth_hash + ) + elif url == URL("http://127.0.0.1:80/app/request"): + return _mock_response(response_status[2], b"") + + 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)) + + with expectation: + await protocol.query({}) + + +async def test_port_override(): + """Test that port override sets the app_url.""" + host = "127.0.0.1" + config = DeviceConfig( + host, credentials=Credentials("foo", "bar"), port_override=12345 + ) + transport = KlapTransport(config=config) + + assert str(transport._app_url) == "http://127.0.0.1:12345/app" diff --git a/tests/transports/test_linkietransport.py b/tests/transports/test_linkietransport.py new file mode 100644 index 000000000..1ac8dba5d --- /dev/null +++ b/tests/transports/test_linkietransport.py @@ -0,0 +1,144 @@ +import base64 +from unittest.mock 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 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): + """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): + """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, return_status, return_data, expected): + """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(): + 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..0105205d8 --- /dev/null +++ b/tests/transports/test_sslaestransport.py @@ -0,0 +1,729 @@ +from __future__ import annotations + +import base64 +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, + 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, + 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 + ) + + +@pytest.mark.parametrize(("want_default"), [True, False]) +@pytest.mark.xdist_group(name="caplog") +async def test_unencrypted_passthrough(mocker, caplog, want_default): + 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, caplog, want_default): + 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): + 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, response, expected_msg): + """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(): + """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, login_version, expected_password_b64 +): + """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..37b797254 --- /dev/null +++ b/tests/transports/test_ssltransport.py @@ -0,0 +1,374 @@ +from __future__ import annotations + +import logging +from base64 import b64encode +from contextlib import nullcontext as does_not_raise +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, + 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, + status_code, + error_code, + username, + password, + expectation, +): + 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): + 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): + 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): + """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): + """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(): + """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/tox.ini b/tox.ini deleted file mode 100644 index 7cc957ed6..000000000 --- a/tox.ini +++ /dev/null @@ -1,43 +0,0 @@ -[tox] -envlist=py37,py38,flake8,lint,coverage -skip_missing_interpreters = True -isolated_build = True - - -[testenv] -whitelist_externals = - poetry - coverage -commands = - poetry install -v - poetry run pytest --cov kasa/tests/ - -[testenv:clean] -deps = coverage -skip_install = true -commands = coverage erase - -[testenv:py37] -commands = coverage run -m pytest {posargs} - -[testenv:py38] -commands = coverage run -m pytest {posargs} - -[testenv:coverage] -basepython = python3.8 -skip_install = true -deps = coverage[toml] -commands = - coverage report - coverage html - -[testenv:flake8] -deps= - flake8 - flake8-docstrings -commands=flake8 kasa - -[testenv:lint] -deps = pre-commit -skip_install = true -commands = pre-commit run --all-files diff --git a/uv.lock b/uv.lock new file mode 100644 index 000000000..fb140077e --- /dev/null +++ b/uv.lock @@ -0,0 +1,1623 @@ +version = 1 +requires-python = ">=3.11, <4.0" + +[[package]] +name = "aiohappyeyeballs" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/07/508f9ebba367fc3370162e53a3cfd12f5652ad79f0e0bfdf9f9847c6f159/aiohappyeyeballs-2.4.6.tar.gz", hash = "sha256:9b05052f9042985d32ecbe4b59a77ae19c006a78f1344d7fdad69d28ded3d0b0", size = 21726 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/4c/03fb05f56551828ec67ceb3665e5dc51638042d204983a03b0a1541475b6/aiohappyeyeballs-2.4.6-py3-none-any.whl", hash = "sha256:147ec992cf873d74f5062644332c539fcd42956dc69453fe5204195e560517e1", size = 14543 }, +] + +[[package]] +name = "aiohttp" +version = "3.11.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/4b/952d49c73084fb790cb5c6ead50848c8e96b4980ad806cf4d2ad341eaa03/aiohttp-3.11.12.tar.gz", hash = "sha256:7603ca26d75b1b86160ce1bbe2787a0b706e592af5b2504e12caa88a217767b0", size = 7673175 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/38/35311e70196b6a63cfa033a7f741f800aa8a93f57442991cbe51da2394e7/aiohttp-3.11.12-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:87a2e00bf17da098d90d4145375f1d985a81605267e7f9377ff94e55c5d769eb", size = 708797 }, + { url = "https://files.pythonhosted.org/packages/44/3e/46c656e68cbfc4f3fc7cb5d2ba4da6e91607fe83428208028156688f6201/aiohttp-3.11.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b34508f1cd928ce915ed09682d11307ba4b37d0708d1f28e5774c07a7674cac9", size = 468669 }, + { url = "https://files.pythonhosted.org/packages/a0/d6/2088fb4fd1e3ac2bfb24bc172223babaa7cdbb2784d33c75ec09e66f62f8/aiohttp-3.11.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:936d8a4f0f7081327014742cd51d320296b56aa6d324461a13724ab05f4b2933", size = 455739 }, + { url = "https://files.pythonhosted.org/packages/e7/dc/c443a6954a56f4a58b5efbfdf23cc6f3f0235e3424faf5a0c56264d5c7bb/aiohttp-3.11.12-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2de1378f72def7dfb5dbd73d86c19eda0ea7b0a6873910cc37d57e80f10d64e1", size = 1685858 }, + { url = "https://files.pythonhosted.org/packages/25/67/2d5b3aaade1d5d01c3b109aa76e3aa9630531252cda10aa02fb99b0b11a1/aiohttp-3.11.12-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9d45dbb3aaec05cf01525ee1a7ac72de46a8c425cb75c003acd29f76b1ffe94", size = 1743829 }, + { url = "https://files.pythonhosted.org/packages/90/9b/9728fe9a3e1b8521198455d027b0b4035522be18f504b24c5d38d59e7278/aiohttp-3.11.12-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:930ffa1925393381e1e0a9b82137fa7b34c92a019b521cf9f41263976666a0d6", size = 1785587 }, + { url = "https://files.pythonhosted.org/packages/ce/cf/28fbb43d4ebc1b4458374a3c7b6db3b556a90e358e9bbcfe6d9339c1e2b6/aiohttp-3.11.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8340def6737118f5429a5df4e88f440746b791f8f1c4ce4ad8a595f42c980bd5", size = 1675319 }, + { url = "https://files.pythonhosted.org/packages/e5/d2/006c459c11218cabaa7bca401f965c9cc828efbdea7e1615d4644eaf23f7/aiohttp-3.11.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4016e383f91f2814e48ed61e6bda7d24c4d7f2402c75dd28f7e1027ae44ea204", size = 1619982 }, + { url = "https://files.pythonhosted.org/packages/9d/83/ca425891ebd37bee5d837110f7fddc4d808a7c6c126a7d1b5c3ad72fc6ba/aiohttp-3.11.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3c0600bcc1adfaaac321422d615939ef300df81e165f6522ad096b73439c0f58", size = 1654176 }, + { url = "https://files.pythonhosted.org/packages/25/df/047b1ce88514a1b4915d252513640184b63624e7914e41d846668b8edbda/aiohttp-3.11.12-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:0450ada317a65383b7cce9576096150fdb97396dcfe559109b403c7242faffef", size = 1660198 }, + { url = "https://files.pythonhosted.org/packages/d3/cc/6ecb8e343f0902528620b9dbd567028a936d5489bebd7dbb0dd0914f4fdb/aiohttp-3.11.12-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:850ff6155371fd802a280f8d369d4e15d69434651b844bde566ce97ee2277420", size = 1650186 }, + { url = "https://files.pythonhosted.org/packages/f8/f8/453df6dd69256ca8c06c53fc8803c9056e2b0b16509b070f9a3b4bdefd6c/aiohttp-3.11.12-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8fd12d0f989c6099e7b0f30dc6e0d1e05499f3337461f0b2b0dadea6c64b89df", size = 1733063 }, + { url = "https://files.pythonhosted.org/packages/55/f8/540160787ff3000391de0e5d0d1d33be4c7972f933c21991e2ea105b2d5e/aiohttp-3.11.12-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:76719dd521c20a58a6c256d058547b3a9595d1d885b830013366e27011ffe804", size = 1755306 }, + { url = "https://files.pythonhosted.org/packages/30/7d/49f3bfdfefd741576157f8f91caa9ff61a6f3d620ca6339268327518221b/aiohttp-3.11.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:97fe431f2ed646a3b56142fc81d238abcbaff08548d6912acb0b19a0cadc146b", size = 1692909 }, + { url = "https://files.pythonhosted.org/packages/40/9c/8ce00afd6f6112ce9a2309dc490fea376ae824708b94b7b5ea9cba979d1d/aiohttp-3.11.12-cp311-cp311-win32.whl", hash = "sha256:e10c440d142fa8b32cfdb194caf60ceeceb3e49807072e0dc3a8887ea80e8c16", size = 416584 }, + { url = "https://files.pythonhosted.org/packages/35/97/4d3c5f562f15830de472eb10a7a222655d750839943e0e6d915ef7e26114/aiohttp-3.11.12-cp311-cp311-win_amd64.whl", hash = "sha256:246067ba0cf5560cf42e775069c5d80a8989d14a7ded21af529a4e10e3e0f0e6", size = 442674 }, + { url = "https://files.pythonhosted.org/packages/4d/d0/94346961acb476569fca9a644cc6f9a02f97ef75961a6b8d2b35279b8d1f/aiohttp-3.11.12-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e392804a38353900c3fd8b7cacbea5132888f7129f8e241915e90b85f00e3250", size = 704837 }, + { url = "https://files.pythonhosted.org/packages/a9/af/05c503f1cc8f97621f199ef4b8db65fb88b8bc74a26ab2adb74789507ad3/aiohttp-3.11.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8fa1510b96c08aaad49303ab11f8803787c99222288f310a62f493faf883ede1", size = 464218 }, + { url = "https://files.pythonhosted.org/packages/f2/48/b9949eb645b9bd699153a2ec48751b985e352ab3fed9d98c8115de305508/aiohttp-3.11.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dc065a4285307607df3f3686363e7f8bdd0d8ab35f12226362a847731516e42c", size = 456166 }, + { url = "https://files.pythonhosted.org/packages/14/fb/980981807baecb6f54bdd38beb1bd271d9a3a786e19a978871584d026dcf/aiohttp-3.11.12-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddb31f8474695cd61fc9455c644fc1606c164b93bff2490390d90464b4655df", size = 1682528 }, + { url = "https://files.pythonhosted.org/packages/90/cb/77b1445e0a716914e6197b0698b7a3640590da6c692437920c586764d05b/aiohttp-3.11.12-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9dec0000d2d8621d8015c293e24589d46fa218637d820894cb7356c77eca3259", size = 1737154 }, + { url = "https://files.pythonhosted.org/packages/ff/24/d6fb1f4cede9ccbe98e4def6f3ed1e1efcb658871bbf29f4863ec646bf38/aiohttp-3.11.12-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3552fe98e90fdf5918c04769f338a87fa4f00f3b28830ea9b78b1bdc6140e0d", size = 1793435 }, + { url = "https://files.pythonhosted.org/packages/17/e2/9f744cee0861af673dc271a3351f59ebd5415928e20080ab85be25641471/aiohttp-3.11.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6dfe7f984f28a8ae94ff3a7953cd9678550dbd2a1f9bda5dd9c5ae627744c78e", size = 1692010 }, + { url = "https://files.pythonhosted.org/packages/90/c4/4a1235c1df544223eb57ba553ce03bc706bdd065e53918767f7fa1ff99e0/aiohttp-3.11.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a481a574af914b6e84624412666cbfbe531a05667ca197804ecc19c97b8ab1b0", size = 1619481 }, + { url = "https://files.pythonhosted.org/packages/60/70/cf12d402a94a33abda86dd136eb749b14c8eb9fec1e16adc310e25b20033/aiohttp-3.11.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1987770fb4887560363b0e1a9b75aa303e447433c41284d3af2840a2f226d6e0", size = 1641578 }, + { url = "https://files.pythonhosted.org/packages/1b/25/7211973fda1f5e833fcfd98ccb7f9ce4fbfc0074e3e70c0157a751d00db8/aiohttp-3.11.12-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a4ac6a0f0f6402854adca4e3259a623f5c82ec3f0c049374133bcb243132baf9", size = 1684463 }, + { url = "https://files.pythonhosted.org/packages/93/60/b5905b4d0693f6018b26afa9f2221fefc0dcbd3773fe2dff1a20fb5727f1/aiohttp-3.11.12-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c96a43822f1f9f69cc5c3706af33239489a6294be486a0447fb71380070d4d5f", size = 1646691 }, + { url = "https://files.pythonhosted.org/packages/b4/fc/ba1b14d6fdcd38df0b7c04640794b3683e949ea10937c8a58c14d697e93f/aiohttp-3.11.12-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a5e69046f83c0d3cb8f0d5bd9b8838271b1bc898e01562a04398e160953e8eb9", size = 1702269 }, + { url = "https://files.pythonhosted.org/packages/5e/39/18c13c6f658b2ba9cc1e0c6fb2d02f98fd653ad2addcdf938193d51a9c53/aiohttp-3.11.12-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:68d54234c8d76d8ef74744f9f9fc6324f1508129e23da8883771cdbb5818cbef", size = 1734782 }, + { url = "https://files.pythonhosted.org/packages/9f/d2/ccc190023020e342419b265861877cd8ffb75bec37b7ddd8521dd2c6deb8/aiohttp-3.11.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9fd9dcf9c91affe71654ef77426f5cf8489305e1c66ed4816f5a21874b094b9", size = 1694740 }, + { url = "https://files.pythonhosted.org/packages/3f/54/186805bcada64ea90ea909311ffedcd74369bfc6e880d39d2473314daa36/aiohttp-3.11.12-cp312-cp312-win32.whl", hash = "sha256:0ed49efcd0dc1611378beadbd97beb5d9ca8fe48579fc04a6ed0844072261b6a", size = 411530 }, + { url = "https://files.pythonhosted.org/packages/3d/63/5eca549d34d141bcd9de50d4e59b913f3641559460c739d5e215693cb54a/aiohttp-3.11.12-cp312-cp312-win_amd64.whl", hash = "sha256:54775858c7f2f214476773ce785a19ee81d1294a6bedc5cc17225355aab74802", size = 437860 }, + { url = "https://files.pythonhosted.org/packages/c3/9b/cea185d4b543ae08ee478373e16653722c19fcda10d2d0646f300ce10791/aiohttp-3.11.12-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:413ad794dccb19453e2b97c2375f2ca3cdf34dc50d18cc2693bd5aed7d16f4b9", size = 698148 }, + { url = "https://files.pythonhosted.org/packages/91/5c/80d47fe7749fde584d1404a68ade29bcd7e58db8fa11fa38e8d90d77e447/aiohttp-3.11.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a93d28ed4b4b39e6f46fd240896c29b686b75e39cc6992692e3922ff6982b4c", size = 460831 }, + { url = "https://files.pythonhosted.org/packages/8e/f9/de568f8a8ca6b061d157c50272620c53168d6e3eeddae78dbb0f7db981eb/aiohttp-3.11.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d589264dbba3b16e8951b6f145d1e6b883094075283dafcab4cdd564a9e353a0", size = 453122 }, + { url = "https://files.pythonhosted.org/packages/8b/fd/b775970a047543bbc1d0f66725ba72acef788028fce215dc959fd15a8200/aiohttp-3.11.12-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5148ca8955affdfeb864aca158ecae11030e952b25b3ae15d4e2b5ba299bad2", size = 1665336 }, + { url = "https://files.pythonhosted.org/packages/82/9b/aff01d4f9716245a1b2965f02044e4474fadd2bcfe63cf249ca788541886/aiohttp-3.11.12-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:525410e0790aab036492eeea913858989c4cb070ff373ec3bc322d700bdf47c1", size = 1718111 }, + { url = "https://files.pythonhosted.org/packages/e0/a9/166fd2d8b2cc64f08104aa614fad30eee506b563154081bf88ce729bc665/aiohttp-3.11.12-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bd8695be2c80b665ae3f05cb584093a1e59c35ecb7d794d1edd96e8cc9201d7", size = 1775293 }, + { url = "https://files.pythonhosted.org/packages/13/c5/0d3c89bd9e36288f10dc246f42518ce8e1c333f27636ac78df091c86bb4a/aiohttp-3.11.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f0203433121484b32646a5f5ea93ae86f3d9559d7243f07e8c0eab5ff8e3f70e", size = 1677338 }, + { url = "https://files.pythonhosted.org/packages/72/b2/017db2833ef537be284f64ead78725984db8a39276c1a9a07c5c7526e238/aiohttp-3.11.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40cd36749a1035c34ba8d8aaf221b91ca3d111532e5ccb5fa8c3703ab1b967ed", size = 1603365 }, + { url = "https://files.pythonhosted.org/packages/fc/72/b66c96a106ec7e791e29988c222141dd1219d7793ffb01e72245399e08d2/aiohttp-3.11.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a7442662afebbf7b4c6d28cb7aab9e9ce3a5df055fc4116cc7228192ad6cb484", size = 1618464 }, + { url = "https://files.pythonhosted.org/packages/3f/50/e68a40f267b46a603bab569d48d57f23508801614e05b3369898c5b2910a/aiohttp-3.11.12-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8a2fb742ef378284a50766e985804bd6adb5adb5aa781100b09befdbfa757b65", size = 1657827 }, + { url = "https://files.pythonhosted.org/packages/c5/1d/aafbcdb1773d0ba7c20793ebeedfaba1f3f7462f6fc251f24983ed738aa7/aiohttp-3.11.12-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cee3b117a8d13ab98b38d5b6bdcd040cfb4181068d05ce0c474ec9db5f3c5bb", size = 1616700 }, + { url = "https://files.pythonhosted.org/packages/b0/5e/6cd9724a2932f36e2a6b742436a36d64784322cfb3406ca773f903bb9a70/aiohttp-3.11.12-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f6a19bcab7fbd8f8649d6595624856635159a6527861b9cdc3447af288a00c00", size = 1685643 }, + { url = "https://files.pythonhosted.org/packages/8b/38/ea6c91d5c767fd45a18151675a07c710ca018b30aa876a9f35b32fa59761/aiohttp-3.11.12-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e4cecdb52aaa9994fbed6b81d4568427b6002f0a91c322697a4bfcc2b2363f5a", size = 1715487 }, + { url = "https://files.pythonhosted.org/packages/8e/24/e9edbcb7d1d93c02e055490348df6f955d675e85a028c33babdcaeda0853/aiohttp-3.11.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:30f546358dfa0953db92ba620101fefc81574f87b2346556b90b5f3ef16e55ce", size = 1672948 }, + { url = "https://files.pythonhosted.org/packages/25/be/0b1fb737268e003198f25c3a68c2135e76e4754bf399a879b27bd508a003/aiohttp-3.11.12-cp313-cp313-win32.whl", hash = "sha256:ce1bb21fc7d753b5f8a5d5a4bae99566386b15e716ebdb410154c16c91494d7f", size = 410396 }, + { url = "https://files.pythonhosted.org/packages/68/fd/677def96a75057b0a26446b62f8fbb084435b20a7d270c99539c26573bfd/aiohttp-3.11.12-cp313-cp313-win_amd64.whl", hash = "sha256:f7914ab70d2ee8ab91c13e5402122edbc77821c66d2758abb53aabe87f013287", size = 436234 }, +] + +[[package]] +name = "aiosignal" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/b5/6d55e80f6d8a08ce22b982eafa278d823b541c925f11ee774b0b9c43473d/aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54", size = 19424 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/6a/bc7e17a3e87a2985d3e8f4da4cd0f481060eb78fb08596c42be62c90a4d9/aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5", size = 7597 }, +] + +[[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.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/73/199a98fc2dae33535d6b8e8e6ec01f8c1d76c9adb096c6b7d64823038cde/anyio-4.8.0.tar.gz", hash = "sha256:1d9fe889df5212298c0c0723fa20479d1b94883a2df44bd3897aa91083316f7a", size = 181126 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566 }, +] + +[[package]] +name = "asyncclick" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "colorama", marker = "platform_system == 'Windows'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/b5/e1e5fdf1c1bb7e6e614987c120a98d9324bf8edfaa5f5cd16a6235c9d91b/asyncclick-8.1.8.tar.gz", hash = "sha256:0f0eb0f280e04919d67cf71b9fcdfb4db2d9ff7203669c40284485c149578e4c", size = 232900 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/cc/a436f0fc2d04e57a0697e0f87a03b9eaed03ad043d2d5f887f8eebcec95f/asyncclick-8.1.8-py3-none-any.whl", hash = "sha256:eb1ccb44bc767f8f0695d592c7806fdf5bd575605b4ee246ffd5fadbcfdbd7c6", size = 99093 }, + { url = "https://files.pythonhosted.org/packages/92/c4/ae9e9d25522c6dc96ff167903880a0fe94d7bd31ed999198ee5017d977ed/asyncclick-8.1.8.0-py3-none-any.whl", hash = "sha256:be146a2d8075d4fe372ff4e877f23c8b5af269d16705c1948123b9415f6fd678", size = 99115 }, +] + +[[package]] +name = "attrs" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/7c/fdf464bcc51d23881d110abd74b512a42b3d5d376a55a831b44c603ae17f/attrs-25.1.0.tar.gz", hash = "sha256:1c97078a80c814273a76b2a298a932eb681c87415c11dee0a6921de7f1b02c3e", size = 810562 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/30/d4986a882011f9df997a55e6becd864812ccfcd821d64aac8570ee39f719/attrs-25.1.0-py3-none-any.whl", hash = "sha256:c75a69e28a550a7e93789579c22aa26b0f5b83b75dc4e08fe092980051e1090a", size = 63152 }, +] + +[[package]] +name = "babel" +version = "2.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/6b/d52e42361e1aa00709585ecc30b3f9684b3ab62530771402248b1b1d6240/babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d", size = 9951852 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537 }, +] + +[[package]] +name = "certifi" +version = "2025.1.31" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +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 }, +] + +[[package]] +name = "cfgv" +version = "3.4.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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/80/41ef5d5a7935d2d3a773e3eaebf0a9350542f2cab4eac59a7a4741fbbbbe/charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125", size = 194995 }, + { url = "https://files.pythonhosted.org/packages/7a/28/0b9fefa7b8b080ec492110af6d88aa3dea91c464b17d53474b6e9ba5d2c5/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1", size = 139471 }, + { url = "https://files.pythonhosted.org/packages/71/64/d24ab1a997efb06402e3fc07317e94da358e2585165930d9d59ad45fcae2/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3", size = 149831 }, + { url = "https://files.pythonhosted.org/packages/37/ed/be39e5258e198655240db5e19e0b11379163ad7070962d6b0c87ed2c4d39/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd", size = 142335 }, + { url = "https://files.pythonhosted.org/packages/88/83/489e9504711fa05d8dde1574996408026bdbdbd938f23be67deebb5eca92/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00", size = 143862 }, + { url = "https://files.pythonhosted.org/packages/c6/c7/32da20821cf387b759ad24627a9aca289d2822de929b8a41b6241767b461/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12", size = 145673 }, + { url = "https://files.pythonhosted.org/packages/68/85/f4288e96039abdd5aeb5c546fa20a37b50da71b5cf01e75e87f16cd43304/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77", size = 140211 }, + { url = "https://files.pythonhosted.org/packages/28/a3/a42e70d03cbdabc18997baf4f0227c73591a08041c149e710045c281f97b/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146", size = 148039 }, + { url = "https://files.pythonhosted.org/packages/85/e4/65699e8ab3014ecbe6f5c71d1a55d810fb716bbfd74f6283d5c2aa87febf/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd", size = 151939 }, + { url = "https://files.pythonhosted.org/packages/b1/82/8e9fe624cc5374193de6860aba3ea8070f584c8565ee77c168ec13274bd2/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6", size = 149075 }, + { url = "https://files.pythonhosted.org/packages/3d/7b/82865ba54c765560c8433f65e8acb9217cb839a9e32b42af4aa8e945870f/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8", size = 144340 }, + { url = "https://files.pythonhosted.org/packages/b5/b6/9674a4b7d4d99a0d2df9b215da766ee682718f88055751e1e5e753c82db0/charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b", size = 95205 }, + { url = "https://files.pythonhosted.org/packages/1e/ab/45b180e175de4402dcf7547e4fb617283bae54ce35c27930a6f35b6bef15/charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76", size = 102441 }, + { url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 }, + { url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 }, + { url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 }, + { url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 }, + { url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 }, + { url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 }, + { url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 }, + { url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 }, + { url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 }, + { url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 }, + { url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 }, + { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 }, + { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 }, + { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 }, + { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 }, + { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 }, + { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 }, + { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 }, + { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 }, + { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 }, + { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 }, + { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 }, + { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 }, + { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 }, + { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 }, + { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 }, + { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 }, +] + +[[package]] +name = "codecov" +version = "2.1.13" +source = { registry = "https://pypi.org/simple" } +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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/02/18785edcdf6266cdd6c6dc7635f1cbeefd9a5b4c3bb8aff8bd681e9dd095/codecov-2.1.13-py2.py3-none-any.whl", hash = "sha256:c2ca5e51bba9ebb43644c43d0690148a55086f7f5e6fd36170858fa4206744d5", size = 16512 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "coverage" +version = "7.6.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/d6/2b53ab3ee99f2262e6f0b8369a43f6d66658eab45510331c0b3d5c8c4272/coverage-7.6.12.tar.gz", hash = "sha256:48cfc4641d95d34766ad41d9573cc0f22a48aa88d22657a1fe01dca0dbae4de2", size = 805941 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/2d/da78abbfff98468c91fd63a73cccdfa0e99051676ded8dd36123e3a2d4d5/coverage-7.6.12-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e18aafdfb3e9ec0d261c942d35bd7c28d031c5855dadb491d2723ba54f4c3015", size = 208464 }, + { url = "https://files.pythonhosted.org/packages/31/f2/c269f46c470bdabe83a69e860c80a82e5e76840e9f4bbd7f38f8cebbee2f/coverage-7.6.12-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66fe626fd7aa5982cdebad23e49e78ef7dbb3e3c2a5960a2b53632f1f703ea45", size = 208893 }, + { url = "https://files.pythonhosted.org/packages/47/63/5682bf14d2ce20819998a49c0deadb81e608a59eed64d6bc2191bc8046b9/coverage-7.6.12-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ef01d70198431719af0b1f5dcbefc557d44a190e749004042927b2a3fed0702", size = 241545 }, + { url = "https://files.pythonhosted.org/packages/6a/b6/6b6631f1172d437e11067e1c2edfdb7238b65dff965a12bce3b6d1bf2be2/coverage-7.6.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e92ae5a289a4bc4c0aae710c0948d3c7892e20fd3588224ebe242039573bf0", size = 239230 }, + { url = "https://files.pythonhosted.org/packages/c7/01/9cd06cbb1be53e837e16f1b4309f6357e2dfcbdab0dd7cd3b1a50589e4e1/coverage-7.6.12-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e695df2c58ce526eeab11a2e915448d3eb76f75dffe338ea613c1201b33bab2f", size = 241013 }, + { url = "https://files.pythonhosted.org/packages/4b/26/56afefc03c30871326e3d99709a70d327ac1f33da383cba108c79bd71563/coverage-7.6.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d74c08e9aaef995f8c4ef6d202dbd219c318450fe2a76da624f2ebb9c8ec5d9f", size = 239750 }, + { url = "https://files.pythonhosted.org/packages/dd/ea/88a1ff951ed288f56aa561558ebe380107cf9132facd0b50bced63ba7238/coverage-7.6.12-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e995b3b76ccedc27fe4f477b349b7d64597e53a43fc2961db9d3fbace085d69d", size = 238462 }, + { url = "https://files.pythonhosted.org/packages/6e/d4/1d9404566f553728889409eff82151d515fbb46dc92cbd13b5337fa0de8c/coverage-7.6.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b1f097878d74fe51e1ddd1be62d8e3682748875b461232cf4b52ddc6e6db0bba", size = 239307 }, + { url = "https://files.pythonhosted.org/packages/12/c1/e453d3b794cde1e232ee8ac1d194fde8e2ba329c18bbf1b93f6f5eef606b/coverage-7.6.12-cp311-cp311-win32.whl", hash = "sha256:1f7ffa05da41754e20512202c866d0ebfc440bba3b0ed15133070e20bf5aeb5f", size = 211117 }, + { url = "https://files.pythonhosted.org/packages/d5/db/829185120c1686fa297294f8fcd23e0422f71070bf85ef1cc1a72ecb2930/coverage-7.6.12-cp311-cp311-win_amd64.whl", hash = "sha256:e216c5c45f89ef8971373fd1c5d8d1164b81f7f5f06bbf23c37e7908d19e8558", size = 212019 }, + { url = "https://files.pythonhosted.org/packages/e2/7f/4af2ed1d06ce6bee7eafc03b2ef748b14132b0bdae04388e451e4b2c529b/coverage-7.6.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b172f8e030e8ef247b3104902cc671e20df80163b60a203653150d2fc204d1ad", size = 208645 }, + { url = "https://files.pythonhosted.org/packages/dc/60/d19df912989117caa95123524d26fc973f56dc14aecdec5ccd7d0084e131/coverage-7.6.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:641dfe0ab73deb7069fb972d4d9725bf11c239c309ce694dd50b1473c0f641c3", size = 208898 }, + { url = "https://files.pythonhosted.org/packages/bd/10/fecabcf438ba676f706bf90186ccf6ff9f6158cc494286965c76e58742fa/coverage-7.6.12-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e549f54ac5f301e8e04c569dfdb907f7be71b06b88b5063ce9d6953d2d58574", size = 242987 }, + { url = "https://files.pythonhosted.org/packages/4c/53/4e208440389e8ea936f5f2b0762dcd4cb03281a7722def8e2bf9dc9c3d68/coverage-7.6.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:959244a17184515f8c52dcb65fb662808767c0bd233c1d8a166e7cf74c9ea985", size = 239881 }, + { url = "https://files.pythonhosted.org/packages/c4/47/2ba744af8d2f0caa1f17e7746147e34dfc5f811fb65fc153153722d58835/coverage-7.6.12-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bda1c5f347550c359f841d6614fb8ca42ae5cb0b74d39f8a1e204815ebe25750", size = 242142 }, + { url = "https://files.pythonhosted.org/packages/e9/90/df726af8ee74d92ee7e3bf113bf101ea4315d71508952bd21abc3fae471e/coverage-7.6.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1ceeb90c3eda1f2d8c4c578c14167dbd8c674ecd7d38e45647543f19839dd6ea", size = 241437 }, + { url = "https://files.pythonhosted.org/packages/f6/af/995263fd04ae5f9cf12521150295bf03b6ba940d0aea97953bb4a6db3e2b/coverage-7.6.12-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f16f44025c06792e0fb09571ae454bcc7a3ec75eeb3c36b025eccf501b1a4c3", size = 239724 }, + { url = "https://files.pythonhosted.org/packages/1c/8e/5bb04f0318805e190984c6ce106b4c3968a9562a400180e549855d8211bd/coverage-7.6.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b076e625396e787448d27a411aefff867db2bffac8ed04e8f7056b07024eed5a", size = 241329 }, + { url = "https://files.pythonhosted.org/packages/9e/9d/fa04d9e6c3f6459f4e0b231925277cfc33d72dfab7fa19c312c03e59da99/coverage-7.6.12-cp312-cp312-win32.whl", hash = "sha256:00b2086892cf06c7c2d74983c9595dc511acca00665480b3ddff749ec4fb2a95", size = 211289 }, + { url = "https://files.pythonhosted.org/packages/53/40/53c7ffe3c0c3fff4d708bc99e65f3d78c129110d6629736faf2dbd60ad57/coverage-7.6.12-cp312-cp312-win_amd64.whl", hash = "sha256:7ae6eabf519bc7871ce117fb18bf14e0e343eeb96c377667e3e5dd12095e0288", size = 212079 }, + { url = "https://files.pythonhosted.org/packages/76/89/1adf3e634753c0de3dad2f02aac1e73dba58bc5a3a914ac94a25b2ef418f/coverage-7.6.12-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:488c27b3db0ebee97a830e6b5a3ea930c4a6e2c07f27a5e67e1b3532e76b9ef1", size = 208673 }, + { url = "https://files.pythonhosted.org/packages/ce/64/92a4e239d64d798535c5b45baac6b891c205a8a2e7c9cc8590ad386693dc/coverage-7.6.12-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d1095bbee1851269f79fd8e0c9b5544e4c00c0c24965e66d8cba2eb5bb535fd", size = 208945 }, + { url = "https://files.pythonhosted.org/packages/b4/d0/4596a3ef3bca20a94539c9b1e10fd250225d1dec57ea78b0867a1cf9742e/coverage-7.6.12-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0533adc29adf6a69c1baa88c3d7dbcaadcffa21afbed3ca7a225a440e4744bf9", size = 242484 }, + { url = "https://files.pythonhosted.org/packages/1c/ef/6fd0d344695af6718a38d0861408af48a709327335486a7ad7e85936dc6e/coverage-7.6.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53c56358d470fa507a2b6e67a68fd002364d23c83741dbc4c2e0680d80ca227e", size = 239525 }, + { url = "https://files.pythonhosted.org/packages/0c/4b/373be2be7dd42f2bcd6964059fd8fa307d265a29d2b9bcf1d044bcc156ed/coverage-7.6.12-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64cbb1a3027c79ca6310bf101014614f6e6e18c226474606cf725238cf5bc2d4", size = 241545 }, + { url = "https://files.pythonhosted.org/packages/a6/7d/0e83cc2673a7790650851ee92f72a343827ecaaea07960587c8f442b5cd3/coverage-7.6.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79cac3390bfa9836bb795be377395f28410811c9066bc4eefd8015258a7578c6", size = 241179 }, + { url = "https://files.pythonhosted.org/packages/ff/8c/566ea92ce2bb7627b0900124e24a99f9244b6c8c92d09ff9f7633eb7c3c8/coverage-7.6.12-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9b148068e881faa26d878ff63e79650e208e95cf1c22bd3f77c3ca7b1d9821a3", size = 239288 }, + { url = "https://files.pythonhosted.org/packages/7d/e4/869a138e50b622f796782d642c15fb5f25a5870c6d0059a663667a201638/coverage-7.6.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8bec2ac5da793c2685ce5319ca9bcf4eee683b8a1679051f8e6ec04c4f2fd7dc", size = 241032 }, + { url = "https://files.pythonhosted.org/packages/ae/28/a52ff5d62a9f9e9fe9c4f17759b98632edd3a3489fce70154c7d66054dd3/coverage-7.6.12-cp313-cp313-win32.whl", hash = "sha256:200e10beb6ddd7c3ded322a4186313d5ca9e63e33d8fab4faa67ef46d3460af3", size = 211315 }, + { url = "https://files.pythonhosted.org/packages/bc/17/ab849b7429a639f9722fa5628364c28d675c7ff37ebc3268fe9840dda13c/coverage-7.6.12-cp313-cp313-win_amd64.whl", hash = "sha256:2b996819ced9f7dbb812c701485d58f261bef08f9b85304d41219b1496b591ef", size = 212099 }, + { url = "https://files.pythonhosted.org/packages/d2/1c/b9965bf23e171d98505eb5eb4fb4d05c44efd256f2e0f19ad1ba8c3f54b0/coverage-7.6.12-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:299cf973a7abff87a30609879c10df0b3bfc33d021e1adabc29138a48888841e", size = 209511 }, + { url = "https://files.pythonhosted.org/packages/57/b3/119c201d3b692d5e17784fee876a9a78e1b3051327de2709392962877ca8/coverage-7.6.12-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b467a8c56974bf06e543e69ad803c6865249d7a5ccf6980457ed2bc50312703", size = 209729 }, + { url = "https://files.pythonhosted.org/packages/52/4e/a7feb5a56b266304bc59f872ea07b728e14d5a64f1ad3a2cc01a3259c965/coverage-7.6.12-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2458f275944db8129f95d91aee32c828a408481ecde3b30af31d552c2ce284a0", size = 253988 }, + { url = "https://files.pythonhosted.org/packages/65/19/069fec4d6908d0dae98126aa7ad08ce5130a6decc8509da7740d36e8e8d2/coverage-7.6.12-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a9d8be07fb0832636a0f72b80d2a652fe665e80e720301fb22b191c3434d924", size = 249697 }, + { url = "https://files.pythonhosted.org/packages/1c/da/5b19f09ba39df7c55f77820736bf17bbe2416bbf5216a3100ac019e15839/coverage-7.6.12-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14d47376a4f445e9743f6c83291e60adb1b127607a3618e3185bbc8091f0467b", size = 252033 }, + { url = "https://files.pythonhosted.org/packages/1e/89/4c2750df7f80a7872267f7c5fe497c69d45f688f7b3afe1297e52e33f791/coverage-7.6.12-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b95574d06aa9d2bd6e5cc35a5bbe35696342c96760b69dc4287dbd5abd4ad51d", size = 251535 }, + { url = "https://files.pythonhosted.org/packages/78/3b/6d3ae3c1cc05f1b0460c51e6f6dcf567598cbd7c6121e5ad06643974703c/coverage-7.6.12-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:ecea0c38c9079570163d663c0433a9af4094a60aafdca491c6a3d248c7432827", size = 249192 }, + { url = "https://files.pythonhosted.org/packages/6e/8e/c14a79f535ce41af7d436bbad0d3d90c43d9e38ec409b4770c894031422e/coverage-7.6.12-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2251fabcfee0a55a8578a9d29cecfee5f2de02f11530e7d5c5a05859aa85aee9", size = 250627 }, + { url = "https://files.pythonhosted.org/packages/cb/79/b7cee656cfb17a7f2c1b9c3cee03dd5d8000ca299ad4038ba64b61a9b044/coverage-7.6.12-cp313-cp313t-win32.whl", hash = "sha256:eb5507795caabd9b2ae3f1adc95f67b1104971c22c624bb354232d65c4fc90b3", size = 212033 }, + { url = "https://files.pythonhosted.org/packages/b6/c3/f7aaa3813f1fa9a4228175a7bd368199659d392897e184435a3b66408dd3/coverage-7.6.12-cp313-cp313t-win_amd64.whl", hash = "sha256:f60a297c3987c6c02ffb29effc70eadcbb412fe76947d394a1091a3615948e2f", size = 213240 }, + { url = "https://files.pythonhosted.org/packages/fb/b2/f655700e1024dec98b10ebaafd0cedbc25e40e4abe62a3c8e2ceef4f8f0a/coverage-7.6.12-py3-none-any.whl", hash = "sha256:eb8668cfbc279a536c633137deeb9435d2962caec279c3f8cf8b91fff6ff8953", size = 200552 }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cryptography" +version = "44.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/67/545c79fe50f7af51dbad56d16b23fe33f63ee6a5d956b3cb68ea110cbe64/cryptography-44.0.1.tar.gz", hash = "sha256:f51f5705ab27898afda1aaa430f34ad90dc117421057782022edf0600bec5f14", size = 710819 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/27/5e3524053b4c8889da65cf7814a9d0d8514a05194a25e1e34f46852ee6eb/cryptography-44.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf688f615c29bfe9dfc44312ca470989279f0e94bb9f631f85e3459af8efc009", size = 6642022 }, + { url = "https://files.pythonhosted.org/packages/34/b9/4d1fa8d73ae6ec350012f89c3abfbff19fc95fe5420cf972e12a8d182986/cryptography-44.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd7c7e2d71d908dc0f8d2027e1604102140d84b155e658c20e8ad1304317691f", size = 3943865 }, + { url = "https://files.pythonhosted.org/packages/6e/57/371a9f3f3a4500807b5fcd29fec77f418ba27ffc629d88597d0d1049696e/cryptography-44.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:887143b9ff6bad2b7570da75a7fe8bbf5f65276365ac259a5d2d5147a73775f2", size = 4162562 }, + { url = "https://files.pythonhosted.org/packages/c5/1d/5b77815e7d9cf1e3166988647f336f87d5634a5ccecec2ffbe08ef8dd481/cryptography-44.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:322eb03ecc62784536bc173f1483e76747aafeb69c8728df48537eb431cd1911", size = 3951923 }, + { url = "https://files.pythonhosted.org/packages/28/01/604508cd34a4024467cd4105887cf27da128cba3edd435b54e2395064bfb/cryptography-44.0.1-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:21377472ca4ada2906bc313168c9dc7b1d7ca417b63c1c3011d0c74b7de9ae69", size = 3685194 }, + { url = "https://files.pythonhosted.org/packages/c6/3d/d3c55d4f1d24580a236a6753902ef6d8aafd04da942a1ee9efb9dc8fd0cb/cryptography-44.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:df978682c1504fc93b3209de21aeabf2375cb1571d4e61907b3e7a2540e83026", size = 4187790 }, + { url = "https://files.pythonhosted.org/packages/ea/a6/44d63950c8588bfa8594fd234d3d46e93c3841b8e84a066649c566afb972/cryptography-44.0.1-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:eb3889330f2a4a148abead555399ec9a32b13b7c8ba969b72d8e500eb7ef84cd", size = 3951343 }, + { url = "https://files.pythonhosted.org/packages/c1/17/f5282661b57301204cbf188254c1a0267dbd8b18f76337f0a7ce1038888c/cryptography-44.0.1-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8e6a85a93d0642bd774460a86513c5d9d80b5c002ca9693e63f6e540f1815ed0", size = 4187127 }, + { url = "https://files.pythonhosted.org/packages/f3/68/abbae29ed4f9d96596687f3ceea8e233f65c9645fbbec68adb7c756bb85a/cryptography-44.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6f76fdd6fd048576a04c5210d53aa04ca34d2ed63336d4abd306d0cbe298fddf", size = 4070666 }, + { url = "https://files.pythonhosted.org/packages/0f/10/cf91691064a9e0a88ae27e31779200b1505d3aee877dbe1e4e0d73b4f155/cryptography-44.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6c8acf6f3d1f47acb2248ec3ea261171a671f3d9428e34ad0357148d492c7864", size = 4288811 }, + { url = "https://files.pythonhosted.org/packages/38/78/74ea9eb547d13c34e984e07ec8a473eb55b19c1451fe7fc8077c6a4b0548/cryptography-44.0.1-cp37-abi3-win32.whl", hash = "sha256:24979e9f2040c953a94bf3c6782e67795a4c260734e5264dceea65c8f4bae64a", size = 2771882 }, + { url = "https://files.pythonhosted.org/packages/cf/6c/3907271ee485679e15c9f5e93eac6aa318f859b0aed8d369afd636fafa87/cryptography-44.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:fd0ee90072861e276b0ff08bd627abec29e32a53b2be44e41dbcdf87cbee2b00", size = 3206989 }, + { url = "https://files.pythonhosted.org/packages/9f/f1/676e69c56a9be9fd1bffa9bc3492366901f6e1f8f4079428b05f1414e65c/cryptography-44.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a2d8a7045e1ab9b9f803f0d9531ead85f90c5f2859e653b61497228b18452008", size = 6643714 }, + { url = "https://files.pythonhosted.org/packages/ba/9f/1775600eb69e72d8f9931a104120f2667107a0ee478f6ad4fe4001559345/cryptography-44.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8272f257cf1cbd3f2e120f14c68bff2b6bdfcc157fafdee84a1b795efd72862", size = 3943269 }, + { url = "https://files.pythonhosted.org/packages/25/ba/e00d5ad6b58183829615be7f11f55a7b6baa5a06910faabdc9961527ba44/cryptography-44.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e8d181e90a777b63f3f0caa836844a1182f1f265687fac2115fcf245f5fbec3", size = 4166461 }, + { url = "https://files.pythonhosted.org/packages/b3/45/690a02c748d719a95ab08b6e4decb9d81e0ec1bac510358f61624c86e8a3/cryptography-44.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:436df4f203482f41aad60ed1813811ac4ab102765ecae7a2bbb1dbb66dcff5a7", size = 3950314 }, + { url = "https://files.pythonhosted.org/packages/e6/50/bf8d090911347f9b75adc20f6f6569ed6ca9b9bff552e6e390f53c2a1233/cryptography-44.0.1-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4f422e8c6a28cf8b7f883eb790695d6d45b0c385a2583073f3cec434cc705e1a", size = 3686675 }, + { url = "https://files.pythonhosted.org/packages/e1/e7/cfb18011821cc5f9b21efb3f94f3241e3a658d267a3bf3a0f45543858ed8/cryptography-44.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:72198e2b5925155497a5a3e8c216c7fb3e64c16ccee11f0e7da272fa93b35c4c", size = 4190429 }, + { url = "https://files.pythonhosted.org/packages/07/ef/77c74d94a8bfc1a8a47b3cafe54af3db537f081742ee7a8a9bd982b62774/cryptography-44.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a46a89ad3e6176223b632056f321bc7de36b9f9b93b2cc1cccf935a3849dc62", size = 3950039 }, + { url = "https://files.pythonhosted.org/packages/6d/b9/8be0ff57c4592382b77406269b1e15650c9f1a167f9e34941b8515b97159/cryptography-44.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:53f23339864b617a3dfc2b0ac8d5c432625c80014c25caac9082314e9de56f41", size = 4189713 }, + { url = "https://files.pythonhosted.org/packages/78/e1/4b6ac5f4100545513b0847a4d276fe3c7ce0eacfa73e3b5ebd31776816ee/cryptography-44.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:888fcc3fce0c888785a4876ca55f9f43787f4c5c1cc1e2e0da71ad481ff82c5b", size = 4071193 }, + { url = "https://files.pythonhosted.org/packages/3d/cb/afff48ceaed15531eab70445abe500f07f8f96af2bb35d98af6bfa89ebd4/cryptography-44.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:00918d859aa4e57db8299607086f793fa7813ae2ff5a4637e318a25ef82730f7", size = 4289566 }, + { url = "https://files.pythonhosted.org/packages/30/6f/4eca9e2e0f13ae459acd1ca7d9f0257ab86e68f44304847610afcb813dc9/cryptography-44.0.1-cp39-abi3-win32.whl", hash = "sha256:9b336599e2cb77b1008cb2ac264b290803ec5e8e89d618a5e978ff5eb6f715d9", size = 2772371 }, + { url = "https://files.pythonhosted.org/packages/d2/05/5533d30f53f10239616a357f080892026db2d550a40c393d0a8a7af834a9/cryptography-44.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:e403f7f766ded778ecdb790da786b418a9f2394f36e8cc8b796cc056ab05f44f", size = 3207303 }, +] + +[[package]] +name = "distlib" +version = "0.3.9" +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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/87/f238c0670b94533ac0353a4e2a1a771a0cc73277b88bff23d3ae35a256c1/docutils-0.20.1-py3-none-any.whl", hash = "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6", size = 572666 }, +] + +[[package]] +name = "execnet" +version = "2.1.1" +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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", size = 40612 }, +] + +[[package]] +name = "filelock" +version = "3.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/9c/0b15fb47b464e1b663b1acd1253a062aa5feecb07d4e597daea542ebd2b5/filelock-3.17.0.tar.gz", hash = "sha256:ee4e77401ef576ebb38cd7f13b9b28893194acc20a8e68e18730ba9c0e54660e", size = 18027 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/ec/00d68c4ddfedfe64159999e5f8a98fb8442729a63e2077eb9dcd89623d27/filelock-3.17.0-py3-none-any.whl", hash = "sha256:533dc2f7ba78dc2f0f531fc6c4940addf7b70a481e269a5a3b93be94ffbe8338", size = 16164 }, +] + +[[package]] +name = "freezegun" +version = "1.5.1" +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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/0b/0d7fee5919bccc1fdc1c2a7528b98f65c6f69b223a3fd8f809918c142c36/freezegun-1.5.1-py3-none-any.whl", hash = "sha256:bf111d7138a8abe55ab48a71755673dbaa4ab87f4cff5634a4442dfec34c15f1", size = 17569 }, +] + +[[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 }, +] + +[[package]] +name = "identify" +version = "2.6.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/d1/524aa3350f78bcd714d148ade6133d67d6b7de2cdbae7d99039c024c9a25/identify-2.6.7.tar.gz", hash = "sha256:3fa266b42eba321ee0b2bb0936a6a6b9e36a1351cbb69055b3082f4193035684", size = 99260 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/00/1fd4a117c6c93f2dcc5b7edaeaf53ea45332ef966429be566ca16c2beb94/identify-2.6.7-py2.py3-none-any.whl", hash = "sha256:155931cb617a401807b09ecec6635d6c692d180090a1cedca8ef7d58ba5b6aa0", size = 99097 }, +] + +[[package]] +name = "idna" +version = "3.10" +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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "imagesize" +version = "1.4.1" +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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/62/85c4c919272577931d407be5ba5d71c20f0b616d31a0befe0ae45bb79abd/imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", size = 8769 }, +] + +[[package]] +name = "iniconfig" +version = "2.0.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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, +] + +[[package]] +name = "jedi" +version = "0.19.2" +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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278 }, +] + +[[package]] +name = "jinja2" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/92/b3130cbbf5591acf9ade8708c365f3238046ac7cb8ccba6e81abccb0ccff/jinja2-3.1.5.tar.gz", hash = "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb", size = 244674 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/0f/2ba5fbcd631e3e88689309dbe978c5769e883e4b84ebfe7da30b43275c5a/jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb", size = 134596 }, +] + +[[package]] +name = "kasa-crypt" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/ab/64fe21b3fa73c31f936468f010c77077c5a3f14e8eae1ff09ccee0d2ed24/kasa_crypt-0.5.0.tar.gz", hash = "sha256:0617e2cbe77d14283769a2290c580cac722ffffa3f8a2fe013492a066810a983", size = 9044 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/e1/ff9231de11fe66bafa8ed4e8fc16d00f8fc95aa1d8d4098bf9b2b4579e6e/kasa_crypt-0.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:19ebd2416b50ac8738dab7c2996c21e03685d5a95de4d03230eb9f17f5b6321e", size = 70144 }, + { url = "https://files.pythonhosted.org/packages/08/68/5da1c2b7aa5c7069a1534634c7196083d003e56c9dc9bd20c61c5ed6071b/kasa_crypt-0.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77820e50f04230b25500d5760385bf71e5192f6c142ee28ebdfb5c8ae194aecd", size = 137598 }, + { url = "https://files.pythonhosted.org/packages/a1/c5/99c3d32f614a8d2179f66effe40d5f3ced88346dc556150716786ee0f686/kasa_crypt-0.5.0-cp311-cp311-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:23b934578408e6fe7a21c86eba6f9210b46763b9e8f9c5cbbd125e35d9ced746", size = 133041 }, + { url = "https://files.pythonhosted.org/packages/b9/77/68cdc119269ccd594abf322ddb079d048d1da498e3a973582178ff2d18cd/kasa_crypt-0.5.0-cp311-cp311-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4bb5aa54080b3dd8ad0b8d0835a291f8997875440a76f202979503d7629220e", size = 136752 }, + { url = "https://files.pythonhosted.org/packages/48/82/fc61569666ba1000cc0e8a91fd05a70d92b75d000668bdec87901e775dab/kasa_crypt-0.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f78185cb15992d90abdcef45b87823398b8f37293677a5ae3cac6b68f1c55c93", size = 135209 }, + { url = "https://files.pythonhosted.org/packages/f7/37/d7240f200cb4974afdb8aca6cbaf0e0bec05e9b6b76b0d3e21d355ac4fda/kasa_crypt-0.5.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2214d8e9807c63ce3b1a505e7169326301b35db6b583a726b0c99c9a3547ee87", size = 133486 }, + { url = "https://files.pythonhosted.org/packages/ec/1a/ef9ad625f237b5deaa5c38053b78a240f6fa45372616306ef174943b8faa/kasa_crypt-0.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4875b09d834ed2ea1bf87bfe9bb18b84f3d5373204df210d12eb9476625ed8a4", size = 135660 }, + { url = "https://files.pythonhosted.org/packages/0d/2a/02b34ff817dc91c09e7f05991f574411f67ca70a1e318cffd9e6f17a5cfe/kasa_crypt-0.5.0-cp311-cp311-win32.whl", hash = "sha256:45a04d4fa16a4ab92978e451a306e9112036cea81f8a42a0090be9c1a6aa77e6", size = 68686 }, + { url = "https://files.pythonhosted.org/packages/08/f1/889620c2dbe417e29e43d4709e408173f3627ce85252ba998602be0f1201/kasa_crypt-0.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:018baf4c123a889a9dfe181354f6a3ce53cf2341d986bb63104a4b91e871a0b6", size = 71022 }, + { url = "https://files.pythonhosted.org/packages/b1/0d/b9f4b21ae5d3c483195675b5be8d859ec0bfa975d794138f294e6bce337a/kasa_crypt-0.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:56a98a13a8e1d5bb73cd21e71f83d02930fd20507da2fa8062e15342116120ad", size = 70374 }, + { url = "https://files.pythonhosted.org/packages/49/de/6143ab15ef50a4b5cdfbad1e2c6b7b89ccd82b55ad119cc5f3b04a591d41/kasa_crypt-0.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:757e273c76483b936382b2d60cf5b8bc75d47b37fe463907be8cf2483a8c68d0", size = 143469 }, + { url = "https://files.pythonhosted.org/packages/82/e7/203f752a33dc4518121e08adc87e5c363b103e4ed3c6f6fd0fa7e8f92271/kasa_crypt-0.5.0-cp312-cp312-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:aa3e482244b107e6eabbd0c8a0ddbc36d5f07648b2075204172cc5a9f7823bea", size = 138802 }, + { url = "https://files.pythonhosted.org/packages/38/d3/e6f10bec474a889138deff95471e7da8d03a78121bb76bf95fee77585435/kasa_crypt-0.5.0-cp312-cp312-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77d29acf928ad85f3e3ff0b758d848719cc62f39c92d9da7ddc91a2cb25e70fa", size = 143670 }, + { url = "https://files.pythonhosted.org/packages/20/70/e3bdb987fbb44887465b2d21a3c3691b6b03674ce1d9bf5d08daa6cf2883/kasa_crypt-0.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a58a04b39292f96b69107ed1aeb21b3259493dc1d799d717ee503e24e290cbc0", size = 140185 }, + { url = "https://files.pythonhosted.org/packages/34/4b/c5841eceb5f35a2c2e72fadae17357ee235b24717a24f4eb98bf1b6d675e/kasa_crypt-0.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b6ede15c4db1c54854afdd565d84d7d48dba90c181abf5ec235ee05e4f42659e", size = 138956 }, + { url = "https://files.pythonhosted.org/packages/88/3f/ac8cb266e8790df5a55d15f89d6d9ee1d3de92b6795a53b758660a8b798a/kasa_crypt-0.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:10c4cde5554ea0ced9b01949ce3c05dde98b73d18bb24c8dc780db607a749cbb", size = 141592 }, + { url = "https://files.pythonhosted.org/packages/b5/75/c70182cb1b14ee43fe38e2ba97bba381dae212d3c3520c16dc6db51572a8/kasa_crypt-0.5.0-cp312-cp312-win32.whl", hash = "sha256:a7bea471d8e08e3f618b99c3721a9dcf492038a3261755275bd67e91ec736ab7", size = 68930 }, + { url = "https://files.pythonhosted.org/packages/af/6b/5bf37d3320d462b57ce7c1e2ac381265067a16ecb4ce5840b29868efad00/kasa_crypt-0.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:36c4cdafe0d73c636ff3beb9f9850a14989800b6e927157bbc34e6f20d39c6a7", size = 71335 }, + { url = "https://files.pythonhosted.org/packages/7a/78/f865240de111154666e9c10785b06c235c0e19c237449e65ae73bab68320/kasa_crypt-0.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23070ff05e127e2a53820e08c28accd171e8189fe93ef3d61d3f553ed3756334", size = 69653 }, + { url = "https://files.pythonhosted.org/packages/ae/6e/fb3fcb634d483748042712529fb2a464a21b5d87efb62fe4f0b43c1dea60/kasa_crypt-0.5.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e02da1f89d4e85371532a38ba533f910be7423a3d60fe0556c1ce67e71d64115", size = 138348 }, + { url = "https://files.pythonhosted.org/packages/38/da/50f026c21a90b545ef7e0044c45f615c10cb7e819f0d4581659889f6759d/kasa_crypt-0.5.0-cp313-cp313-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:837f9087dbc86b6417965e1cbe2df173a2a4c31fd8c93af8ccf73bd74bc4434e", size = 133713 }, + { url = "https://files.pythonhosted.org/packages/63/43/24500819c29d2129d2699adbdd99e59147339ae66a7a26863a87b71bdf47/kasa_crypt-0.5.0-cp313-cp313-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36ebb8a724c2a1b98688c5d35c20d4236fb7b027948aa46d2991539fddfd884d", size = 138460 }, + { url = "https://files.pythonhosted.org/packages/82/3a/c1a20c2d9ba9ca148477aa71e634bd34545ed81bd5feddbc88201454372d/kasa_crypt-0.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:28f2f36a2c279af1cbf2ee261570ce7fca651cce72bb5954200b1be53ae8ef84", size = 135412 }, + { url = "https://files.pythonhosted.org/packages/02/e4/fb439c4862e258272b813e42fe292cea5c7b6a98ea20bf5bfb45b857d021/kasa_crypt-0.5.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6a0183ac7128fffe5600a161ef63ab86adc51efc587765c2b48f3f50ec7467ac", size = 133794 }, + { url = "https://files.pythonhosted.org/packages/b1/e1/7f990f6f6e2fd53f48fa3739a11d8a5435f4d6847000febac2b9dc746cf8/kasa_crypt-0.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51ed2bf8575f051dc7e9d2e7e126ce57468df0d6d410dfa227157802e5094dbe", size = 136888 }, + { url = "https://files.pythonhosted.org/packages/1e/a5/7b8c52532d54bc93bcb212fae284d810b0483b46401d8d70c69d0f9584a6/kasa_crypt-0.5.0-cp313-cp313-win32.whl", hash = "sha256:6bdf19dedee9454b3c4ef3874399e99bcdc908c047dfbb01165842eca5773512", size = 68283 }, + { url = "https://files.pythonhosted.org/packages/9b/48/399d7c1933c51c821a8d51837b335720d1d6d4e35bd24f74ced69c3ab937/kasa_crypt-0.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:8909208e4c038518b33f7a9e757accd6793cc5f0490370aeef0a3d9e1705f5c4", size = 70493 }, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +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 } +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 }, +] + +[[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 }, +] + +[[package]] +name = "mashumaro" +version = "3.15" +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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/59/595eabaa779c87a72d65864351e0fdd2359d7d73967d5ed9f2f0c6186fa3/mashumaro-3.15-py3-none-any.whl", hash = "sha256:cdd45ef5a4d09860846a3ee37a4c2f5f4bc70eb158caa55648c4c99451ca6c4c", size = 93761 }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.4.2" +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 } +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 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[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 }, +] + +[[package]] +name = "mypy" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/43/d5e49a86afa64bd3839ea0d5b9c7103487007d728e1293f52525d6d5486a/mypy-1.15.0.tar.gz", hash = "sha256:404534629d51d3efea5c800ee7c42b72a6554d6c400e6a79eafe15d11341fd43", size = 3239717 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/bc/f6339726c627bd7ca1ce0fa56c9ae2d0144604a319e0e339bdadafbbb599/mypy-1.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2922d42e16d6de288022e5ca321cd0618b238cfc5570e0263e5ba0a77dbef56f", size = 10662338 }, + { url = "https://files.pythonhosted.org/packages/e2/90/8dcf506ca1a09b0d17555cc00cd69aee402c203911410136cd716559efe7/mypy-1.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2ee2d57e01a7c35de00f4634ba1bbf015185b219e4dc5909e281016df43f5ee5", size = 9787540 }, + { url = "https://files.pythonhosted.org/packages/05/05/a10f9479681e5da09ef2f9426f650d7b550d4bafbef683b69aad1ba87457/mypy-1.15.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:973500e0774b85d9689715feeffcc980193086551110fd678ebe1f4342fb7c5e", size = 11538051 }, + { url = "https://files.pythonhosted.org/packages/e9/9a/1f7d18b30edd57441a6411fcbc0c6869448d1a4bacbaee60656ac0fc29c8/mypy-1.15.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a95fb17c13e29d2d5195869262f8125dfdb5c134dc8d9a9d0aecf7525b10c2c", size = 12286751 }, + { url = "https://files.pythonhosted.org/packages/72/af/19ff499b6f1dafcaf56f9881f7a965ac2f474f69f6f618b5175b044299f5/mypy-1.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1905f494bfd7d85a23a88c5d97840888a7bd516545fc5aaedff0267e0bb54e2f", size = 12421783 }, + { url = "https://files.pythonhosted.org/packages/96/39/11b57431a1f686c1aed54bf794870efe0f6aeca11aca281a0bd87a5ad42c/mypy-1.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:c9817fa23833ff189db061e6d2eff49b2f3b6ed9856b4a0a73046e41932d744f", size = 9265618 }, + { url = "https://files.pythonhosted.org/packages/98/3a/03c74331c5eb8bd025734e04c9840532226775c47a2c39b56a0c8d4f128d/mypy-1.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:aea39e0583d05124836ea645f412e88a5c7d0fd77a6d694b60d9b6b2d9f184fd", size = 10793981 }, + { url = "https://files.pythonhosted.org/packages/f0/1a/41759b18f2cfd568848a37c89030aeb03534411eef981df621d8fad08a1d/mypy-1.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f2147ab812b75e5b5499b01ade1f4a81489a147c01585cda36019102538615f", size = 9749175 }, + { url = "https://files.pythonhosted.org/packages/12/7e/873481abf1ef112c582db832740f4c11b2bfa510e829d6da29b0ab8c3f9c/mypy-1.15.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce436f4c6d218a070048ed6a44c0bbb10cd2cc5e272b29e7845f6a2f57ee4464", size = 11455675 }, + { url = "https://files.pythonhosted.org/packages/b3/d0/92ae4cde706923a2d3f2d6c39629134063ff64b9dedca9c1388363da072d/mypy-1.15.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8023ff13985661b50a5928fc7a5ca15f3d1affb41e5f0a9952cb68ef090b31ee", size = 12410020 }, + { url = "https://files.pythonhosted.org/packages/46/8b/df49974b337cce35f828ba6fda228152d6db45fed4c86ba56ffe442434fd/mypy-1.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1124a18bc11a6a62887e3e137f37f53fbae476dc36c185d549d4f837a2a6a14e", size = 12498582 }, + { url = "https://files.pythonhosted.org/packages/13/50/da5203fcf6c53044a0b699939f31075c45ae8a4cadf538a9069b165c1050/mypy-1.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:171a9ca9a40cd1843abeca0e405bc1940cd9b305eaeea2dda769ba096932bb22", size = 9366614 }, + { url = "https://files.pythonhosted.org/packages/6a/9b/fd2e05d6ffff24d912f150b87db9e364fa8282045c875654ce7e32fffa66/mypy-1.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93faf3fdb04768d44bf28693293f3904bbb555d076b781ad2530214ee53e3445", size = 10788592 }, + { url = "https://files.pythonhosted.org/packages/74/37/b246d711c28a03ead1fd906bbc7106659aed7c089d55fe40dd58db812628/mypy-1.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:811aeccadfb730024c5d3e326b2fbe9249bb7413553f15499a4050f7c30e801d", size = 9753611 }, + { url = "https://files.pythonhosted.org/packages/a6/ac/395808a92e10cfdac8003c3de9a2ab6dc7cde6c0d2a4df3df1b815ffd067/mypy-1.15.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98b7b9b9aedb65fe628c62a6dc57f6d5088ef2dfca37903a7d9ee374d03acca5", size = 11438443 }, + { url = "https://files.pythonhosted.org/packages/d2/8b/801aa06445d2de3895f59e476f38f3f8d610ef5d6908245f07d002676cbf/mypy-1.15.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43a7682e24b4f576d93072216bf56eeff70d9140241f9edec0c104d0c515036", size = 12402541 }, + { url = "https://files.pythonhosted.org/packages/c7/67/5a4268782eb77344cc613a4cf23540928e41f018a9a1ec4c6882baf20ab8/mypy-1.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:baefc32840a9f00babd83251560e0ae1573e2f9d1b067719479bfb0e987c6357", size = 12494348 }, + { url = "https://files.pythonhosted.org/packages/83/3e/57bb447f7bbbfaabf1712d96f9df142624a386d98fb026a761532526057e/mypy-1.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:b9378e2c00146c44793c98b8d5a61039a048e31f429fb0eb546d93f4b000bedf", size = 9373648 }, + { url = "https://files.pythonhosted.org/packages/09/4e/a7d65c7322c510de2c409ff3828b03354a7c43f5a8ed458a7a131b41c7b9/mypy-1.15.0-py3-none-any.whl", hash = "sha256:5469affef548bd1895d86d3bf10ce2b44e33d86923c29e4d675b3e323437ea3e", size = 2221777 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, +] + +[[package]] +name = "myst-parser" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "jinja2" }, + { name = "markdown-it-py" }, + { name = "mdit-py-plugins" }, + { name = "pyyaml" }, + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/a5/9626ba4f73555b3735ad86247a8077d4603aa8628537687c839ab08bfe44/myst_parser-4.0.1.tar.gz", hash = "sha256:5cfea715e4f3574138aecbf7d54132296bfd72bb614d31168f48c477a830a7c4", size = 93985 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl", hash = "sha256:9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d", size = 84579 }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, +] + +[[package]] +name = "orjson" +version = "3.10.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/5dea21763eeff8c1590076918a446ea3d6140743e0e36f58f369928ed0f4/orjson-3.10.15.tar.gz", hash = "sha256:05ca7fe452a2e9d8d9d706a2984c95b9c2ebc5db417ce0b7a49b91d50642a23e", size = 5282482 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/a2/21b25ce4a2c71dbb90948ee81bd7a42b4fbfc63162e57faf83157d5540ae/orjson-3.10.15-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:c4cc83960ab79a4031f3119cc4b1a1c627a3dc09df125b27c4201dff2af7eaa6", size = 249533 }, + { url = "https://files.pythonhosted.org/packages/b2/85/2076fc12d8225698a51278009726750c9c65c846eda741e77e1761cfef33/orjson-3.10.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ddbeef2481d895ab8be5185f2432c334d6dec1f5d1933a9c83014d188e102cef", size = 125230 }, + { url = "https://files.pythonhosted.org/packages/06/df/a85a7955f11274191eccf559e8481b2be74a7c6d43075d0a9506aa80284d/orjson-3.10.15-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9e590a0477b23ecd5b0ac865b1b907b01b3c5535f5e8a8f6ab0e503efb896334", size = 150148 }, + { url = "https://files.pythonhosted.org/packages/37/b3/94c55625a29b8767c0eed194cb000b3787e3c23b4cdd13be17bae6ccbb4b/orjson-3.10.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a6be38bd103d2fd9bdfa31c2720b23b5d47c6796bcb1d1b598e3924441b4298d", size = 139749 }, + { url = "https://files.pythonhosted.org/packages/53/ba/c608b1e719971e8ddac2379f290404c2e914cf8e976369bae3cad88768b1/orjson-3.10.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ff4f6edb1578960ed628a3b998fa54d78d9bb3e2eb2cfc5c2a09732431c678d0", size = 154558 }, + { url = "https://files.pythonhosted.org/packages/b2/c4/c1fb835bb23ad788a39aa9ebb8821d51b1c03588d9a9e4ca7de5b354fdd5/orjson-3.10.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0482b21d0462eddd67e7fce10b89e0b6ac56570424662b685a0d6fccf581e13", size = 130349 }, + { url = "https://files.pythonhosted.org/packages/78/14/bb2b48b26ab3c570b284eb2157d98c1ef331a8397f6c8bd983b270467f5c/orjson-3.10.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bb5cc3527036ae3d98b65e37b7986a918955f85332c1ee07f9d3f82f3a6899b5", size = 138513 }, + { url = "https://files.pythonhosted.org/packages/4a/97/d5b353a5fe532e92c46467aa37e637f81af8468aa894cd77d2ec8a12f99e/orjson-3.10.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d569c1c462912acdd119ccbf719cf7102ea2c67dd03b99edcb1a3048651ac96b", size = 130942 }, + { url = "https://files.pythonhosted.org/packages/b5/5d/a067bec55293cca48fea8b9928cfa84c623be0cce8141d47690e64a6ca12/orjson-3.10.15-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1e6d33efab6b71d67f22bf2962895d3dc6f82a6273a965fab762e64fa90dc399", size = 414717 }, + { url = "https://files.pythonhosted.org/packages/6f/9a/1485b8b05c6b4c4db172c438cf5db5dcfd10e72a9bc23c151a1137e763e0/orjson-3.10.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c33be3795e299f565681d69852ac8c1bc5c84863c0b0030b2b3468843be90388", size = 141033 }, + { url = "https://files.pythonhosted.org/packages/f8/d2/fc67523656e43a0c7eaeae9007c8b02e86076b15d591e9be11554d3d3138/orjson-3.10.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:eea80037b9fae5339b214f59308ef0589fc06dc870578b7cce6d71eb2096764c", size = 129720 }, + { url = "https://files.pythonhosted.org/packages/79/42/f58c7bd4e5b54da2ce2ef0331a39ccbbaa7699b7f70206fbf06737c9ed7d/orjson-3.10.15-cp311-cp311-win32.whl", hash = "sha256:d5ac11b659fd798228a7adba3e37c010e0152b78b1982897020a8e019a94882e", size = 142473 }, + { url = "https://files.pythonhosted.org/packages/00/f8/bb60a4644287a544ec81df1699d5b965776bc9848d9029d9f9b3402ac8bb/orjson-3.10.15-cp311-cp311-win_amd64.whl", hash = "sha256:cf45e0214c593660339ef63e875f32ddd5aa3b4adc15e662cdb80dc49e194f8e", size = 133570 }, + { url = "https://files.pythonhosted.org/packages/66/85/22fe737188905a71afcc4bf7cc4c79cd7f5bbe9ed1fe0aac4ce4c33edc30/orjson-3.10.15-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9d11c0714fc85bfcf36ada1179400862da3288fc785c30e8297844c867d7505a", size = 249504 }, + { url = "https://files.pythonhosted.org/packages/48/b7/2622b29f3afebe938a0a9037e184660379797d5fd5234e5998345d7a5b43/orjson-3.10.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dba5a1e85d554e3897fa9fe6fbcff2ed32d55008973ec9a2b992bd9a65d2352d", size = 125080 }, + { url = "https://files.pythonhosted.org/packages/ce/8f/0b72a48f4403d0b88b2a41450c535b3e8989e8a2d7800659a967efc7c115/orjson-3.10.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7723ad949a0ea502df656948ddd8b392780a5beaa4c3b5f97e525191b102fff0", size = 150121 }, + { url = "https://files.pythonhosted.org/packages/06/ec/acb1a20cd49edb2000be5a0404cd43e3c8aad219f376ac8c60b870518c03/orjson-3.10.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6fd9bc64421e9fe9bd88039e7ce8e58d4fead67ca88e3a4014b143cec7684fd4", size = 139796 }, + { url = "https://files.pythonhosted.org/packages/33/e1/f7840a2ea852114b23a52a1c0b2bea0a1ea22236efbcdb876402d799c423/orjson-3.10.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dadba0e7b6594216c214ef7894c4bd5f08d7c0135f4dd0145600be4fbcc16767", size = 154636 }, + { url = "https://files.pythonhosted.org/packages/fa/da/31543337febd043b8fa80a3b67de627669b88c7b128d9ad4cc2ece005b7a/orjson-3.10.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b48f59114fe318f33bbaee8ebeda696d8ccc94c9e90bc27dbe72153094e26f41", size = 130621 }, + { url = "https://files.pythonhosted.org/packages/ed/78/66115dc9afbc22496530d2139f2f4455698be444c7c2475cb48f657cefc9/orjson-3.10.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:035fb83585e0f15e076759b6fedaf0abb460d1765b6a36f48018a52858443514", size = 138516 }, + { url = "https://files.pythonhosted.org/packages/22/84/cd4f5fb5427ffcf823140957a47503076184cb1ce15bcc1165125c26c46c/orjson-3.10.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d13b7fe322d75bf84464b075eafd8e7dd9eae05649aa2a5354cfa32f43c59f17", size = 130762 }, + { url = "https://files.pythonhosted.org/packages/93/1f/67596b711ba9f56dd75d73b60089c5c92057f1130bb3a25a0f53fb9a583b/orjson-3.10.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7066b74f9f259849629e0d04db6609db4cf5b973248f455ba5d3bd58a4daaa5b", size = 414700 }, + { url = "https://files.pythonhosted.org/packages/7c/0c/6a3b3271b46443d90efb713c3e4fe83fa8cd71cda0d11a0f69a03f437c6e/orjson-3.10.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:88dc3f65a026bd3175eb157fea994fca6ac7c4c8579fc5a86fc2114ad05705b7", size = 141077 }, + { url = "https://files.pythonhosted.org/packages/3b/9b/33c58e0bfc788995eccd0d525ecd6b84b40d7ed182dd0751cd4c1322ac62/orjson-3.10.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b342567e5465bd99faa559507fe45e33fc76b9fb868a63f1642c6bc0735ad02a", size = 129898 }, + { url = "https://files.pythonhosted.org/packages/01/c1/d577ecd2e9fa393366a1ea0a9267f6510d86e6c4bb1cdfb9877104cac44c/orjson-3.10.15-cp312-cp312-win32.whl", hash = "sha256:0a4f27ea5617828e6b58922fdbec67b0aa4bb844e2d363b9244c47fa2180e665", size = 142566 }, + { url = "https://files.pythonhosted.org/packages/ed/eb/a85317ee1732d1034b92d56f89f1de4d7bf7904f5c8fb9dcdd5b1c83917f/orjson-3.10.15-cp312-cp312-win_amd64.whl", hash = "sha256:ef5b87e7aa9545ddadd2309efe6824bd3dd64ac101c15dae0f2f597911d46eaa", size = 133732 }, + { url = "https://files.pythonhosted.org/packages/06/10/fe7d60b8da538e8d3d3721f08c1b7bff0491e8fa4dd3bf11a17e34f4730e/orjson-3.10.15-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bae0e6ec2b7ba6895198cd981b7cca95d1487d0147c8ed751e5632ad16f031a6", size = 249399 }, + { url = "https://files.pythonhosted.org/packages/6b/83/52c356fd3a61abd829ae7e4366a6fe8e8863c825a60d7ac5156067516edf/orjson-3.10.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f93ce145b2db1252dd86af37d4165b6faa83072b46e3995ecc95d4b2301b725a", size = 125044 }, + { url = "https://files.pythonhosted.org/packages/55/b2/d06d5901408e7ded1a74c7c20d70e3a127057a6d21355f50c90c0f337913/orjson-3.10.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7c203f6f969210128af3acae0ef9ea6aab9782939f45f6fe02d05958fe761ef9", size = 150066 }, + { url = "https://files.pythonhosted.org/packages/75/8c/60c3106e08dc593a861755781c7c675a566445cc39558677d505878d879f/orjson-3.10.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8918719572d662e18b8af66aef699d8c21072e54b6c82a3f8f6404c1f5ccd5e0", size = 139737 }, + { url = "https://files.pythonhosted.org/packages/6a/8c/ae00d7d0ab8a4490b1efeb01ad4ab2f1982e69cc82490bf8093407718ff5/orjson-3.10.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f71eae9651465dff70aa80db92586ad5b92df46a9373ee55252109bb6b703307", size = 154804 }, + { url = "https://files.pythonhosted.org/packages/22/86/65dc69bd88b6dd254535310e97bc518aa50a39ef9c5a2a5d518e7a223710/orjson-3.10.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e117eb299a35f2634e25ed120c37c641398826c2f5a3d3cc39f5993b96171b9e", size = 130583 }, + { url = "https://files.pythonhosted.org/packages/bb/00/6fe01ededb05d52be42fabb13d93a36e51f1fd9be173bd95707d11a8a860/orjson-3.10.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:13242f12d295e83c2955756a574ddd6741c81e5b99f2bef8ed8d53e47a01e4b7", size = 138465 }, + { url = "https://files.pythonhosted.org/packages/db/2f/4cc151c4b471b0cdc8cb29d3eadbce5007eb0475d26fa26ed123dca93b33/orjson-3.10.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7946922ada8f3e0b7b958cc3eb22cfcf6c0df83d1fe5521b4a100103e3fa84c8", size = 130742 }, + { url = "https://files.pythonhosted.org/packages/9f/13/8a6109e4b477c518498ca37963d9c0eb1508b259725553fb53d53b20e2ea/orjson-3.10.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b7155eb1623347f0f22c38c9abdd738b287e39b9982e1da227503387b81b34ca", size = 414669 }, + { url = "https://files.pythonhosted.org/packages/22/7b/1d229d6d24644ed4d0a803de1b0e2df832032d5beda7346831c78191b5b2/orjson-3.10.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:208beedfa807c922da4e81061dafa9c8489c6328934ca2a562efa707e049e561", size = 141043 }, + { url = "https://files.pythonhosted.org/packages/cc/d3/6dc91156cf12ed86bed383bcb942d84d23304a1e57b7ab030bf60ea130d6/orjson-3.10.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eca81f83b1b8c07449e1d6ff7074e82e3fd6777e588f1a6632127f286a968825", size = 129826 }, + { url = "https://files.pythonhosted.org/packages/b3/38/c47c25b86f6996f1343be721b6ea4367bc1c8bc0fc3f6bbcd995d18cb19d/orjson-3.10.15-cp313-cp313-win32.whl", hash = "sha256:c03cd6eea1bd3b949d0d007c8d57049aa2b39bd49f58b4b2af571a5d3833d890", size = 142542 }, + { url = "https://files.pythonhosted.org/packages/27/f1/1d7ec15b20f8ce9300bc850de1e059132b88990e46cd0ccac29cbf11e4f9/orjson-3.10.15-cp313-cp313-win_amd64.whl", hash = "sha256:fd56a26a04f6ba5fb2045b0acc487a63162a958ed837648c5781e1fe3316cfbf", size = 133444 }, +] + +[[package]] +name = "packaging" +version = "24.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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, +] + +[[package]] +name = "parso" +version = "0.8.4" +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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650 }, +] + +[[package]] +name = "platformdirs" +version = "4.3.6" +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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, +] + +[[package]] +name = "pluggy" +version = "1.5.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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + +[[package]] +name = "pre-commit" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/13/b62d075317d8686071eb843f0bb1f195eb332f48869d3c31a4c6f1e063ac/pre_commit-4.1.0.tar.gz", hash = "sha256:ae3f018575a588e30dfddfab9a05448bfbd6b73d78709617b5a2b853549716d4", size = 193330 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/b3/df14c580d82b9627d173ceea305ba898dca135feb360b6d84019d0803d3b/pre_commit-4.1.0-py2.py3-none-any.whl", hash = "sha256:d29e7cb346295bcc1cc75fc3e92e343495e3ea0196c9ec6ba53f49f10ab6ae7b", size = 220560 }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.50" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/e1/bd15cb8ffdcfeeb2bdc215de3c3cffca11408d829e4b8416dcfe71ba8854/prompt_toolkit-3.0.50.tar.gz", hash = "sha256:544748f3860a2623ca5cd6d2795e7a14f3d0e1c3c9728359013f79877fc89bab", size = 429087 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/ea/d836f008d33151c7a1f62caf3d8dd782e4d15f6a43897f64480c2b8de2ad/prompt_toolkit-3.0.50-py3-none-any.whl", hash = "sha256:9b6427eb19e479d98acff65196a307c555eb567989e6d88ebbb1b509d9779198", size = 387816 }, +] + +[[package]] +name = "propcache" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/c8/2a13f78d82211490855b2fb303b6721348d0787fdd9a12ac46d99d3acde1/propcache-0.2.1.tar.gz", hash = "sha256:3f77ce728b19cb537714499928fe800c3dda29e8d9428778fc7c186da4c09a64", size = 41735 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/0f/2913b6791ebefb2b25b4efd4bb2299c985e09786b9f5b19184a88e5778dd/propcache-0.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ffc3cca89bb438fb9c95c13fc874012f7b9466b89328c3c8b1aa93cdcfadd16", size = 79297 }, + { url = "https://files.pythonhosted.org/packages/cf/73/af2053aeccd40b05d6e19058419ac77674daecdd32478088b79375b9ab54/propcache-0.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f174bbd484294ed9fdf09437f889f95807e5f229d5d93588d34e92106fbf6717", size = 45611 }, + { url = "https://files.pythonhosted.org/packages/3c/09/8386115ba7775ea3b9537730e8cf718d83bbf95bffe30757ccf37ec4e5da/propcache-0.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:70693319e0b8fd35dd863e3e29513875eb15c51945bf32519ef52927ca883bc3", size = 45146 }, + { url = "https://files.pythonhosted.org/packages/03/7a/793aa12f0537b2e520bf09f4c6833706b63170a211ad042ca71cbf79d9cb/propcache-0.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b480c6a4e1138e1aa137c0079b9b6305ec6dcc1098a8ca5196283e8a49df95a9", size = 232136 }, + { url = "https://files.pythonhosted.org/packages/f1/38/b921b3168d72111769f648314100558c2ea1d52eb3d1ba7ea5c4aa6f9848/propcache-0.2.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d27b84d5880f6d8aa9ae3edb253c59d9f6642ffbb2c889b78b60361eed449787", size = 239706 }, + { url = "https://files.pythonhosted.org/packages/14/29/4636f500c69b5edea7786db3c34eb6166f3384b905665ce312a6e42c720c/propcache-0.2.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:857112b22acd417c40fa4595db2fe28ab900c8c5fe4670c7989b1c0230955465", size = 238531 }, + { url = "https://files.pythonhosted.org/packages/85/14/01fe53580a8e1734ebb704a3482b7829a0ef4ea68d356141cf0994d9659b/propcache-0.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf6c4150f8c0e32d241436526f3c3f9cbd34429492abddbada2ffcff506c51af", size = 231063 }, + { url = "https://files.pythonhosted.org/packages/33/5c/1d961299f3c3b8438301ccfbff0143b69afcc30c05fa28673cface692305/propcache-0.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66d4cfda1d8ed687daa4bc0274fcfd5267873db9a5bc0418c2da19273040eeb7", size = 220134 }, + { url = "https://files.pythonhosted.org/packages/00/d0/ed735e76db279ba67a7d3b45ba4c654e7b02bc2f8050671ec365d8665e21/propcache-0.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2f992c07c0fca81655066705beae35fc95a2fa7366467366db627d9f2ee097f", size = 220009 }, + { url = "https://files.pythonhosted.org/packages/75/90/ee8fab7304ad6533872fee982cfff5a53b63d095d78140827d93de22e2d4/propcache-0.2.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:4a571d97dbe66ef38e472703067021b1467025ec85707d57e78711c085984e54", size = 212199 }, + { url = "https://files.pythonhosted.org/packages/eb/ec/977ffaf1664f82e90737275873461695d4c9407d52abc2f3c3e24716da13/propcache-0.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bb6178c241278d5fe853b3de743087be7f5f4c6f7d6d22a3b524d323eecec505", size = 214827 }, + { url = "https://files.pythonhosted.org/packages/57/48/031fb87ab6081764054821a71b71942161619549396224cbb242922525e8/propcache-0.2.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ad1af54a62ffe39cf34db1aa6ed1a1873bd548f6401db39d8e7cd060b9211f82", size = 228009 }, + { url = "https://files.pythonhosted.org/packages/1a/06/ef1390f2524850838f2390421b23a8b298f6ce3396a7cc6d39dedd4047b0/propcache-0.2.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e7048abd75fe40712005bcfc06bb44b9dfcd8e101dda2ecf2f5aa46115ad07ca", size = 231638 }, + { url = "https://files.pythonhosted.org/packages/38/2a/101e6386d5a93358395da1d41642b79c1ee0f3b12e31727932b069282b1d/propcache-0.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:160291c60081f23ee43d44b08a7e5fb76681221a8e10b3139618c5a9a291b84e", size = 222788 }, + { url = "https://files.pythonhosted.org/packages/db/81/786f687951d0979007e05ad9346cd357e50e3d0b0f1a1d6074df334b1bbb/propcache-0.2.1-cp311-cp311-win32.whl", hash = "sha256:819ce3b883b7576ca28da3861c7e1a88afd08cc8c96908e08a3f4dd64a228034", size = 40170 }, + { url = "https://files.pythonhosted.org/packages/cf/59/7cc7037b295d5772eceb426358bb1b86e6cab4616d971bd74275395d100d/propcache-0.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:edc9fc7051e3350643ad929df55c451899bb9ae6d24998a949d2e4c87fb596d3", size = 44404 }, + { url = "https://files.pythonhosted.org/packages/4c/28/1d205fe49be8b1b4df4c50024e62480a442b1a7b818e734308bb0d17e7fb/propcache-0.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:081a430aa8d5e8876c6909b67bd2d937bfd531b0382d3fdedb82612c618bc41a", size = 79588 }, + { url = "https://files.pythonhosted.org/packages/21/ee/fc4d893f8d81cd4971affef2a6cb542b36617cd1d8ce56b406112cb80bf7/propcache-0.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2ccec9ac47cf4e04897619c0e0c1a48c54a71bdf045117d3a26f80d38ab1fb0", size = 45825 }, + { url = "https://files.pythonhosted.org/packages/4a/de/bbe712f94d088da1d237c35d735f675e494a816fd6f54e9db2f61ef4d03f/propcache-0.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:14d86fe14b7e04fa306e0c43cdbeebe6b2c2156a0c9ce56b815faacc193e320d", size = 45357 }, + { url = "https://files.pythonhosted.org/packages/7f/14/7ae06a6cf2a2f1cb382586d5a99efe66b0b3d0c6f9ac2f759e6f7af9d7cf/propcache-0.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:049324ee97bb67285b49632132db351b41e77833678432be52bdd0289c0e05e4", size = 241869 }, + { url = "https://files.pythonhosted.org/packages/cc/59/227a78be960b54a41124e639e2c39e8807ac0c751c735a900e21315f8c2b/propcache-0.2.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1cd9a1d071158de1cc1c71a26014dcdfa7dd3d5f4f88c298c7f90ad6f27bb46d", size = 247884 }, + { url = "https://files.pythonhosted.org/packages/84/58/f62b4ffaedf88dc1b17f04d57d8536601e4e030feb26617228ef930c3279/propcache-0.2.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98110aa363f1bb4c073e8dcfaefd3a5cea0f0834c2aab23dda657e4dab2f53b5", size = 248486 }, + { url = "https://files.pythonhosted.org/packages/1c/07/ebe102777a830bca91bbb93e3479cd34c2ca5d0361b83be9dbd93104865e/propcache-0.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:647894f5ae99c4cf6bb82a1bb3a796f6e06af3caa3d32e26d2350d0e3e3faf24", size = 243649 }, + { url = "https://files.pythonhosted.org/packages/ed/bc/4f7aba7f08f520376c4bb6a20b9a981a581b7f2e385fa0ec9f789bb2d362/propcache-0.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfd3223c15bebe26518d58ccf9a39b93948d3dcb3e57a20480dfdd315356baff", size = 229103 }, + { url = "https://files.pythonhosted.org/packages/fe/d5/04ac9cd4e51a57a96f78795e03c5a0ddb8f23ec098b86f92de028d7f2a6b/propcache-0.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d71264a80f3fcf512eb4f18f59423fe82d6e346ee97b90625f283df56aee103f", size = 226607 }, + { url = "https://files.pythonhosted.org/packages/e3/f0/24060d959ea41d7a7cc7fdbf68b31852331aabda914a0c63bdb0e22e96d6/propcache-0.2.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e73091191e4280403bde6c9a52a6999d69cdfde498f1fdf629105247599b57ec", size = 221153 }, + { url = "https://files.pythonhosted.org/packages/77/a7/3ac76045a077b3e4de4859a0753010765e45749bdf53bd02bc4d372da1a0/propcache-0.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3935bfa5fede35fb202c4b569bb9c042f337ca4ff7bd540a0aa5e37131659348", size = 222151 }, + { url = "https://files.pythonhosted.org/packages/e7/af/5e29da6f80cebab3f5a4dcd2a3240e7f56f2c4abf51cbfcc99be34e17f0b/propcache-0.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f508b0491767bb1f2b87fdfacaba5f7eddc2f867740ec69ece6d1946d29029a6", size = 233812 }, + { url = "https://files.pythonhosted.org/packages/8c/89/ebe3ad52642cc5509eaa453e9f4b94b374d81bae3265c59d5c2d98efa1b4/propcache-0.2.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1672137af7c46662a1c2be1e8dc78cb6d224319aaa40271c9257d886be4363a6", size = 238829 }, + { url = "https://files.pythonhosted.org/packages/e9/2f/6b32f273fa02e978b7577159eae7471b3cfb88b48563b1c2578b2d7ca0bb/propcache-0.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b74c261802d3d2b85c9df2dfb2fa81b6f90deeef63c2db9f0e029a3cac50b518", size = 230704 }, + { url = "https://files.pythonhosted.org/packages/5c/2e/f40ae6ff5624a5f77edd7b8359b208b5455ea113f68309e2b00a2e1426b6/propcache-0.2.1-cp312-cp312-win32.whl", hash = "sha256:d09c333d36c1409d56a9d29b3a1b800a42c76a57a5a8907eacdbce3f18768246", size = 40050 }, + { url = "https://files.pythonhosted.org/packages/3b/77/a92c3ef994e47180862b9d7d11e37624fb1c00a16d61faf55115d970628b/propcache-0.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:c214999039d4f2a5b2073ac506bba279945233da8c786e490d411dfc30f855c1", size = 44117 }, + { url = "https://files.pythonhosted.org/packages/0f/2a/329e0547cf2def8857157f9477669043e75524cc3e6251cef332b3ff256f/propcache-0.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aca405706e0b0a44cc6bfd41fbe89919a6a56999157f6de7e182a990c36e37bc", size = 77002 }, + { url = "https://files.pythonhosted.org/packages/12/2d/c4df5415e2382f840dc2ecbca0eeb2293024bc28e57a80392f2012b4708c/propcache-0.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:12d1083f001ace206fe34b6bdc2cb94be66d57a850866f0b908972f90996b3e9", size = 44639 }, + { url = "https://files.pythonhosted.org/packages/d0/5a/21aaa4ea2f326edaa4e240959ac8b8386ea31dedfdaa636a3544d9e7a408/propcache-0.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d93f3307ad32a27bda2e88ec81134b823c240aa3abb55821a8da553eed8d9439", size = 44049 }, + { url = "https://files.pythonhosted.org/packages/4e/3e/021b6cd86c0acc90d74784ccbb66808b0bd36067a1bf3e2deb0f3845f618/propcache-0.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba278acf14471d36316159c94a802933d10b6a1e117b8554fe0d0d9b75c9d536", size = 224819 }, + { url = "https://files.pythonhosted.org/packages/3c/57/c2fdeed1b3b8918b1770a133ba5c43ad3d78e18285b0c06364861ef5cc38/propcache-0.2.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4e6281aedfca15301c41f74d7005e6e3f4ca143584ba696ac69df4f02f40d629", size = 229625 }, + { url = "https://files.pythonhosted.org/packages/9d/81/70d4ff57bf2877b5780b466471bebf5892f851a7e2ca0ae7ffd728220281/propcache-0.2.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5b750a8e5a1262434fb1517ddf64b5de58327f1adc3524a5e44c2ca43305eb0b", size = 232934 }, + { url = "https://files.pythonhosted.org/packages/3c/b9/bb51ea95d73b3fb4100cb95adbd4e1acaf2cbb1fd1083f5468eeb4a099a8/propcache-0.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf72af5e0fb40e9babf594308911436c8efde3cb5e75b6f206c34ad18be5c052", size = 227361 }, + { url = "https://files.pythonhosted.org/packages/f1/20/3c6d696cd6fd70b29445960cc803b1851a1131e7a2e4ee261ee48e002bcd/propcache-0.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2d0a12018b04f4cb820781ec0dffb5f7c7c1d2a5cd22bff7fb055a2cb19ebce", size = 213904 }, + { url = "https://files.pythonhosted.org/packages/a1/cb/1593bfc5ac6d40c010fa823f128056d6bc25b667f5393781e37d62f12005/propcache-0.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e800776a79a5aabdb17dcc2346a7d66d0777e942e4cd251defeb084762ecd17d", size = 212632 }, + { url = "https://files.pythonhosted.org/packages/6d/5c/e95617e222be14a34c709442a0ec179f3207f8a2b900273720501a70ec5e/propcache-0.2.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:4160d9283bd382fa6c0c2b5e017acc95bc183570cd70968b9202ad6d8fc48dce", size = 207897 }, + { url = "https://files.pythonhosted.org/packages/8e/3b/56c5ab3dc00f6375fbcdeefdede5adf9bee94f1fab04adc8db118f0f9e25/propcache-0.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:30b43e74f1359353341a7adb783c8f1b1c676367b011709f466f42fda2045e95", size = 208118 }, + { url = "https://files.pythonhosted.org/packages/86/25/d7ef738323fbc6ebcbce33eb2a19c5e07a89a3df2fded206065bd5e868a9/propcache-0.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:58791550b27d5488b1bb52bc96328456095d96206a250d28d874fafe11b3dfaf", size = 217851 }, + { url = "https://files.pythonhosted.org/packages/b3/77/763e6cef1852cf1ba740590364ec50309b89d1c818e3256d3929eb92fabf/propcache-0.2.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0f022d381747f0dfe27e99d928e31bc51a18b65bb9e481ae0af1380a6725dd1f", size = 222630 }, + { url = "https://files.pythonhosted.org/packages/4f/e9/0f86be33602089c701696fbed8d8c4c07b6ee9605c5b7536fd27ed540c5b/propcache-0.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:297878dc9d0a334358f9b608b56d02e72899f3b8499fc6044133f0d319e2ec30", size = 216269 }, + { url = "https://files.pythonhosted.org/packages/cc/02/5ac83217d522394b6a2e81a2e888167e7ca629ef6569a3f09852d6dcb01a/propcache-0.2.1-cp313-cp313-win32.whl", hash = "sha256:ddfab44e4489bd79bda09d84c430677fc7f0a4939a73d2bba3073036f487a0a6", size = 39472 }, + { url = "https://files.pythonhosted.org/packages/f4/33/d6f5420252a36034bc8a3a01171bc55b4bff5df50d1c63d9caa50693662f/propcache-0.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:556fc6c10989f19a179e4321e5d678db8eb2924131e64652a51fe83e4c3db0e1", size = 43363 }, + { url = "https://files.pythonhosted.org/packages/41/b6/c5319caea262f4821995dca2107483b94a3345d4607ad797c76cb9c36bcc/propcache-0.2.1-py3-none-any.whl", hash = "sha256:52277518d6aae65536e9cea52d4e7fd2f7a66f4aa2d30ed3f2fcea620ace3c54", size = 11818 }, +] + +[[package]] +name = "ptpython" +version = "3.0.29" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appdirs" }, + { name = "jedi" }, + { 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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/39/c6fd4dd531e067b6a01624126cff0b3ddc6569e22f83e48d8418ffa9e3be/ptpython-3.0.29-py2.py3-none-any.whl", hash = "sha256:65d75c4871859e4305a020c9b9e204366dceb4d08e0e2bd7b7511bd5e917a402", size = 67057 }, +] + +[[package]] +name = "pycparser" +version = "2.22" +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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, +] + +[[package]] +name = "pytest" +version = "8.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 }, +] + +[[package]] +name = "pytest-asyncio" +version = "0.25.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/a8/ecbc8ede70921dd2f544ab1cadd3ff3bf842af27f87bbdea774c7baa1d38/pytest_asyncio-0.25.3.tar.gz", hash = "sha256:fc1da2cf9f125ada7e710b4ddad05518d4cee187ae9412e9ac9271003497f07a", size = 54239 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/17/3493c5624e48fd97156ebaec380dcaafee9506d7e2c46218ceebbb57d7de/pytest_asyncio-0.25.3-py3-none-any.whl", hash = "sha256:9e89518e0f9bd08928f97a3482fdc4e244df17529460bc038291ccaf8f85c7c3", size = 19467 }, +] + +[[package]] +name = "pytest-cov" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949 }, +] + +[[package]] +name = "pytest-freezer" +version = "0.4.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "freezegun" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/f0/98dcbc5324064360b19850b14c84cea9ca50785d921741dbfc442346e925/pytest_freezer-0.4.9.tar.gz", hash = "sha256:21bf16bc9cc46bf98f94382c4b5c3c389be7056ff0be33029111ae11b3f1c82a", size = 3177 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/e9/30252bc05bcf67200a17f4f0b4cc7598f0a68df4fa9fa356193aa899f145/pytest_freezer-0.4.9-py3-none-any.whl", hash = "sha256:8b6c50523b7d4aec4590b52bfa5ff766d772ce506e2bf4846c88041ea9ccae59", size = 3192 }, +] + +[[package]] +name = "pytest-mock" +version = "3.14.0" +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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/3b/b26f90f74e2986a82df6e7ac7e319b8ea7ccece1caec9f8ab6104dc70603/pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f", size = 9863 }, +] + +[[package]] +name = "pytest-socket" +version = "0.7.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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/58/5d14cb5cb59409e491ebe816c47bf81423cd03098ea92281336320ae5681/pytest_socket-0.7.0-py3-none-any.whl", hash = "sha256:7e0f4642177d55d317bbd58fc68c6bd9048d6eadb2d46a89307fa9221336ce45", size = 6754 }, +] + +[[package]] +name = "pytest-sugar" +version = "1.0.0" +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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/fb/889f1b69da2f13691de09a111c16c4766a433382d44aa0ecf221deded44a/pytest_sugar-1.0.0-py3-none-any.whl", hash = "sha256:70ebcd8fc5795dc457ff8b69d266a4e2e8a74ae0c3edc749381c64b5246c8dfd", size = 10171 }, +] + +[[package]] +name = "pytest-timeout" +version = "2.3.1" +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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/27/14af9ef8321f5edc7527e47def2a21d8118c6f329a9342cc61387a0c0599/pytest_timeout-2.3.1-py3-none-any.whl", hash = "sha256:68188cb703edfc6a18fad98dc25a3c61e9f24d644b0b70f33af545219fc7813e", size = 14148 }, +] + +[[package]] +name = "pytest-xdist" +version = "3.6.1" +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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/82/1d96bf03ee4c0fdc3c0cbe61470070e659ca78dc0086fb88b66c185e2449/pytest_xdist-3.6.1-py3-none-any.whl", hash = "sha256:9ed4adfb68a016610848639bb7e02c9352d5d9f03d04809919e2dafc3be4cca7", size = 46108 }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +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 } +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 }, +] + +[[package]] +name = "python-kasa" +version = "0.10.2" +source = { editable = "." } +dependencies = [ + { name = "aiohttp" }, + { name = "asyncclick" }, + { name = "cryptography" }, + { name = "mashumaro" }, + { name = "tzdata", marker = "platform_system == 'Windows'" }, +] + +[package.optional-dependencies] +docs = [ + { name = "docutils" }, + { name = "myst-parser" }, + { name = "sphinx" }, + { name = "sphinx-rtd-theme" }, + { name = "sphinxcontrib-programoutput" }, +] +shell = [ + { name = "ptpython" }, + { name = "rich" }, +] +speedups = [ + { name = "kasa-crypt" }, + { name = "orjson" }, +] + +[package.dev-dependencies] +dev = [ + { name = "codecov" }, + { name = "coverage", extra = ["toml"] }, + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-freezer" }, + { name = "pytest-mock" }, + { name = "pytest-socket" }, + { name = "pytest-sugar" }, + { name = "pytest-timeout" }, + { name = "pytest-xdist" }, + { name = "ruff" }, + { name = "toml" }, + { name = "voluptuous" }, + { name = "xdoctest" }, +] + +[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 = "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" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "codecov" }, + { name = "coverage", extras = ["toml"] }, + { name = "mypy", specifier = "~=1.0" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-freezer", specifier = "~=0.4" }, + { name = "pytest-mock" }, + { name = "pytest-socket", specifier = ">=0.7.0" }, + { name = "pytest-sugar" }, + { name = "pytest-timeout", specifier = "~=2.0" }, + { name = "pytest-xdist", specifier = ">=3.6.1" }, + { name = "ruff", specifier = ">=0.9.0" }, + { name = "toml" }, + { name = "voluptuous" }, + { name = "xdoctest", specifier = ">=1.2.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 }, +] + +[[package]] +name = "requests" +version = "2.32.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { 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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, +] + +[[package]] +name = "rich" +version = "13.9.4" +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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424 }, +] + +[[package]] +name = "ruff" +version = "0.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/e1/e265aba384343dd8ddd3083f5e33536cd17e1566c41453a5517b5dd443be/ruff-0.9.6.tar.gz", hash = "sha256:81761592f72b620ec8fa1068a6fd00e98a5ebee342a3642efd84454f3031dca9", size = 3639454 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/e3/3d2c022e687e18cf5d93d6bfa2722d46afc64eaa438c7fbbdd603b3597be/ruff-0.9.6-py3-none-linux_armv6l.whl", hash = "sha256:2f218f356dd2d995839f1941322ff021c72a492c470f0b26a34f844c29cdf5ba", size = 11714128 }, + { url = "https://files.pythonhosted.org/packages/e1/22/aff073b70f95c052e5c58153cba735748c9e70107a77d03420d7850710a0/ruff-0.9.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b908ff4df65dad7b251c9968a2e4560836d8f5487c2f0cc238321ed951ea0504", size = 11682539 }, + { url = "https://files.pythonhosted.org/packages/75/a7/f5b7390afd98a7918582a3d256cd3e78ba0a26165a467c1820084587cbf9/ruff-0.9.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b109c0ad2ececf42e75fa99dc4043ff72a357436bb171900714a9ea581ddef83", size = 11132512 }, + { url = "https://files.pythonhosted.org/packages/a6/e3/45de13ef65047fea2e33f7e573d848206e15c715e5cd56095589a7733d04/ruff-0.9.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1de4367cca3dac99bcbd15c161404e849bb0bfd543664db39232648dc00112dc", size = 11929275 }, + { url = "https://files.pythonhosted.org/packages/7d/f2/23d04cd6c43b2e641ab961ade8d0b5edb212ecebd112506188c91f2a6e6c/ruff-0.9.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac3ee4d7c2c92ddfdaedf0bf31b2b176fa7aa8950efc454628d477394d35638b", size = 11466502 }, + { url = "https://files.pythonhosted.org/packages/b5/6f/3a8cf166f2d7f1627dd2201e6cbc4cb81f8b7d58099348f0c1ff7b733792/ruff-0.9.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5dc1edd1775270e6aa2386119aea692039781429f0be1e0949ea5884e011aa8e", size = 12676364 }, + { url = "https://files.pythonhosted.org/packages/f5/c4/db52e2189983c70114ff2b7e3997e48c8318af44fe83e1ce9517570a50c6/ruff-0.9.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4a091729086dffa4bd070aa5dab7e39cc6b9d62eb2bef8f3d91172d30d599666", size = 13335518 }, + { url = "https://files.pythonhosted.org/packages/66/44/545f8a4d136830f08f4d24324e7db957c5374bf3a3f7a6c0bc7be4623a37/ruff-0.9.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d1bbc6808bf7b15796cef0815e1dfb796fbd383e7dbd4334709642649625e7c5", size = 12823287 }, + { url = "https://files.pythonhosted.org/packages/c5/26/8208ef9ee7431032c143649a9967c3ae1aae4257d95e6f8519f07309aa66/ruff-0.9.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:589d1d9f25b5754ff230dce914a174a7c951a85a4e9270613a2b74231fdac2f5", size = 14592374 }, + { url = "https://files.pythonhosted.org/packages/31/70/e917781e55ff39c5b5208bda384fd397ffd76605e68544d71a7e40944945/ruff-0.9.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc61dd5131742e21103fbbdcad683a8813be0e3c204472d520d9a5021ca8b217", size = 12500173 }, + { url = "https://files.pythonhosted.org/packages/84/f5/e4ddee07660f5a9622a9c2b639afd8f3104988dc4f6ba0b73ffacffa9a8c/ruff-0.9.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5e2d9126161d0357e5c8f30b0bd6168d2c3872372f14481136d13de9937f79b6", size = 11906555 }, + { url = "https://files.pythonhosted.org/packages/f1/2b/6ff2fe383667075eef8656b9892e73dd9b119b5e3add51298628b87f6429/ruff-0.9.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:68660eab1a8e65babb5229a1f97b46e3120923757a68b5413d8561f8a85d4897", size = 11538958 }, + { url = "https://files.pythonhosted.org/packages/3c/db/98e59e90de45d1eb46649151c10a062d5707b5b7f76f64eb1e29edf6ebb1/ruff-0.9.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c4cae6c4cc7b9b4017c71114115db0445b00a16de3bcde0946273e8392856f08", size = 12117247 }, + { url = "https://files.pythonhosted.org/packages/ec/bc/54e38f6d219013a9204a5a2015c09e7a8c36cedcd50a4b01ac69a550b9d9/ruff-0.9.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:19f505b643228b417c1111a2a536424ddde0db4ef9023b9e04a46ed8a1cb4656", size = 12554647 }, + { url = "https://files.pythonhosted.org/packages/a5/7d/7b461ab0e2404293c0627125bb70ac642c2e8d55bf590f6fce85f508f1b2/ruff-0.9.6-py3-none-win32.whl", hash = "sha256:194d8402bceef1b31164909540a597e0d913c0e4952015a5b40e28c146121b5d", size = 9949214 }, + { url = "https://files.pythonhosted.org/packages/ee/30/c3cee10f915ed75a5c29c1e57311282d1a15855551a64795c1b2bbe5cf37/ruff-0.9.6-py3-none-win_amd64.whl", hash = "sha256:03482d5c09d90d4ee3f40d97578423698ad895c87314c4de39ed2af945633caa", size = 10999914 }, + { url = "https://files.pythonhosted.org/packages/e8/a8/d71f44b93e3aa86ae232af1f2126ca7b95c0f515ec135462b3e1f351441c/ruff-0.9.6-py3-none-win_arm64.whl", hash = "sha256:0e2bb706a2be7ddfea4a4af918562fdc1bcb16df255e5fa595bbd800ce322a5a", size = 10177499 }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[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 }, +] + +[[package]] +name = "snowballstemmer" +version = "2.2.0" +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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/dc/c02e01294f7265e63a7315fe086dd1df7dacb9f840a804da846b96d01b96/snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a", size = 93002 }, +] + +[[package]] +name = "sphinx" +version = "7.4.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "alabaster" }, + { name = "babel" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "docutils" }, + { name = "imagesize" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pygments" }, + { name = "requests" }, + { name = "snowballstemmer" }, + { name = "sphinxcontrib-applehelp" }, + { name = "sphinxcontrib-devhelp" }, + { name = "sphinxcontrib-htmlhelp" }, + { name = "sphinxcontrib-jsmath" }, + { 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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/ef/153f6803c5d5f8917dbb7f7fcf6d34a871ede3296fa89c2c703f5f8a6c8e/sphinx-7.4.7-py3-none-any.whl", hash = "sha256:c2419e2135d11f1951cd994d6eb18a1835bd8fdd8429f9ca375dc1f3281bd239", size = 3401624 }, +] + +[[package]] +name = "sphinx-rtd-theme" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { 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 } +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 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705 }, +] + +[[package]] +name = "sphinxcontrib-jquery" +version = "4.1" +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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/85/749bd22d1a68db7291c89e2ebca53f4306c3f205853cf31e9de279034c3c/sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae", size = 121104 }, +] + +[[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 } +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 }, +] + +[[package]] +name = "sphinxcontrib-programoutput" +version = "0.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/c0/834af2290f8477213ec0dd60e90104f5644aa0c37b1a0d6f0a2b5efe03c4/sphinxcontrib_programoutput-0.18.tar.gz", hash = "sha256:09e68b6411d937a80b6085f4fdeaa42e0dc5555480385938465f410589d2eed8", size = 26333 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/2c/7aec6e0580f666d4f61474a50c4995a98abfff27d827f0e7bc8c4fa528f5/sphinxcontrib_programoutput-0.18-py3-none-any.whl", hash = "sha256:8a651bc85de69a808a064ff0e48d06c12b9347da4fe5fdb1e94914b01e1b0c36", size = 20346 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072 }, +] + +[[package]] +name = "termcolor" +version = "2.5.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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/be/df630c387a0a054815d60be6a97eb4e8f17385d5d6fe660e1c02750062b4/termcolor-2.5.0-py3-none-any.whl", hash = "sha256:37b17b5fc1e604945c2642c872a3764b5d547a48009871aea3edd3afa180afb8", size = 7755 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588 }, +] + +[[package]] +name = "tomli" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077 }, + { url = "https://files.pythonhosted.org/packages/c7/16/51ae563a8615d472fdbffc43a3f3d46588c264ac4f024f63f01283becfbb/tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6", size = 123429 }, + { url = "https://files.pythonhosted.org/packages/f1/dd/4f6cd1e7b160041db83c694abc78e100473c15d54620083dbd5aae7b990e/tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a", size = 226067 }, + { url = "https://files.pythonhosted.org/packages/a9/6b/c54ede5dc70d648cc6361eaf429304b02f2871a345bbdd51e993d6cdf550/tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee", size = 236030 }, + { url = "https://files.pythonhosted.org/packages/1f/47/999514fa49cfaf7a92c805a86c3c43f4215621855d151b61c602abb38091/tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e", size = 240898 }, + { url = "https://files.pythonhosted.org/packages/73/41/0a01279a7ae09ee1573b423318e7934674ce06eb33f50936655071d81a24/tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4", size = 229894 }, + { url = "https://files.pythonhosted.org/packages/55/18/5d8bc5b0a0362311ce4d18830a5d28943667599a60d20118074ea1b01bb7/tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106", size = 245319 }, + { url = "https://files.pythonhosted.org/packages/92/a3/7ade0576d17f3cdf5ff44d61390d4b3febb8a9fc2b480c75c47ea048c646/tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8", size = 238273 }, + { url = "https://files.pythonhosted.org/packages/72/6f/fa64ef058ac1446a1e51110c375339b3ec6be245af9d14c87c4a6412dd32/tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff", size = 98310 }, + { url = "https://files.pythonhosted.org/packages/6a/1c/4a2dcde4a51b81be3530565e92eda625d94dafb46dbeb15069df4caffc34/tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b", size = 108309 }, + { url = "https://files.pythonhosted.org/packages/52/e1/f8af4c2fcde17500422858155aeb0d7e93477a0d59a98e56cbfe75070fd0/tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea", size = 132762 }, + { url = "https://files.pythonhosted.org/packages/03/b8/152c68bb84fc00396b83e7bbddd5ec0bd3dd409db4195e2a9b3e398ad2e3/tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8", size = 123453 }, + { url = "https://files.pythonhosted.org/packages/c8/d6/fc9267af9166f79ac528ff7e8c55c8181ded34eb4b0e93daa767b8841573/tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192", size = 233486 }, + { url = "https://files.pythonhosted.org/packages/5c/51/51c3f2884d7bab89af25f678447ea7d297b53b5a3b5730a7cb2ef6069f07/tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222", size = 242349 }, + { url = "https://files.pythonhosted.org/packages/ab/df/bfa89627d13a5cc22402e441e8a931ef2108403db390ff3345c05253935e/tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77", size = 252159 }, + { url = "https://files.pythonhosted.org/packages/9e/6e/fa2b916dced65763a5168c6ccb91066f7639bdc88b48adda990db10c8c0b/tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6", size = 237243 }, + { url = "https://files.pythonhosted.org/packages/b4/04/885d3b1f650e1153cbb93a6a9782c58a972b94ea4483ae4ac5cedd5e4a09/tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd", size = 259645 }, + { url = "https://files.pythonhosted.org/packages/9c/de/6b432d66e986e501586da298e28ebeefd3edc2c780f3ad73d22566034239/tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e", size = 244584 }, + { url = "https://files.pythonhosted.org/packages/1c/9a/47c0449b98e6e7d1be6cbac02f93dd79003234ddc4aaab6ba07a9a7482e2/tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98", size = 98875 }, + { url = "https://files.pythonhosted.org/packages/ef/60/9b9638f081c6f1261e2688bd487625cd1e660d0a85bd469e91d8db969734/tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4", size = 109418 }, + { url = "https://files.pythonhosted.org/packages/04/90/2ee5f2e0362cb8a0b6499dc44f4d7d48f8fff06d28ba46e6f1eaa61a1388/tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7", size = 132708 }, + { url = "https://files.pythonhosted.org/packages/c0/ec/46b4108816de6b385141f082ba99e315501ccd0a2ea23db4a100dd3990ea/tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c", size = 123582 }, + { url = "https://files.pythonhosted.org/packages/a0/bd/b470466d0137b37b68d24556c38a0cc819e8febe392d5b199dcd7f578365/tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13", size = 232543 }, + { url = "https://files.pythonhosted.org/packages/d9/e5/82e80ff3b751373f7cead2815bcbe2d51c895b3c990686741a8e56ec42ab/tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281", size = 241691 }, + { url = "https://files.pythonhosted.org/packages/05/7e/2a110bc2713557d6a1bfb06af23dd01e7dde52b6ee7dadc589868f9abfac/tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272", size = 251170 }, + { url = "https://files.pythonhosted.org/packages/64/7b/22d713946efe00e0adbcdfd6d1aa119ae03fd0b60ebed51ebb3fa9f5a2e5/tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140", size = 236530 }, + { url = "https://files.pythonhosted.org/packages/38/31/3a76f67da4b0cf37b742ca76beaf819dca0ebef26d78fc794a576e08accf/tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2", size = 258666 }, + { url = "https://files.pythonhosted.org/packages/07/10/5af1293da642aded87e8a988753945d0cf7e00a9452d3911dd3bb354c9e2/tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744", size = 243954 }, + { url = "https://files.pythonhosted.org/packages/5b/b9/1ed31d167be802da0fc95020d04cd27b7d7065cc6fbefdd2f9186f60d7bd/tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec", size = 98724 }, + { url = "https://files.pythonhosted.org/packages/c7/32/b0963458706accd9afcfeb867c0f9175a741bf7b19cd424230714d722198/tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69", size = 109383 }, + { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, +] + +[[package]] +name = "tzdata" +version = "2025.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/0f/fa4723f22942480be4ca9527bbde8d43f6c3f2fe8412f00e7f5f6746bc8b/tzdata-2025.1.tar.gz", hash = "sha256:24894909e88cdb28bd1636c6887801df64cb485bd593f2fd83ef29075a81d694", size = 194950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/dd/84f10e23edd882c6f968c21c2434fe67bd4a528967067515feca9e611e5e/tzdata-2025.1-py2.py3-none-any.whl", hash = "sha256:7e127113816800496f027041c570f50bcd464a020098a3b6b199517772303639", size = 346762 }, +] + +[[package]] +name = "urllib3" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, +] + +[[package]] +name = "virtualenv" +version = "20.29.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/88/dacc875dd54a8acadb4bcbfd4e3e86df8be75527116c91d8f9784f5e9cab/virtualenv-20.29.2.tar.gz", hash = "sha256:fdaabebf6d03b5ba83ae0a02cfe96f48a716f4fae556461d180825866f75b728", size = 4320272 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/fa/849483d56773ae29740ae70043ad88e068f98a6401aa819b5d6bee604683/virtualenv-20.29.2-py3-none-any.whl", hash = "sha256:febddfc3d1ea571bdb1dc0f98d7b45d24def7428214d4fb73cc486c9568cce6a", size = 4301478 }, +] + +[[package]] +name = "voluptuous" +version = "0.15.2" +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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/a8/8f9cc6749331186e6a513bfe3745454f81d25f6e34c6024f88f80c71ed28/voluptuous-0.15.2-py3-none-any.whl", hash = "sha256:016348bc7788a9af9520b1764ebd4de0df41fe2138ebe9e06fa036bf86a65566", size = 31349 }, +] + +[[package]] +name = "wcwidth" +version = "0.2.13" +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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166 }, +] + +[[package]] +name = "xdoctest" +version = "1.2.0" +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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/b8/e4722f5e5f592a665cc8e55a334ea721c359f09574e6b987dc551a1e1f4c/xdoctest-1.2.0-py3-none-any.whl", hash = "sha256:0f1ecf5939a687bd1fc8deefbff1743c65419cce26dff908f8b84c93fbe486bc", size = 151194 }, +] + +[[package]] +name = "yarl" +version = "1.18.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b7/9d/4b94a8e6d2b51b599516a5cb88e5bc99b4d8d4583e468057eaa29d5f0918/yarl-1.18.3.tar.gz", hash = "sha256:ac1801c45cbf77b6c99242eeff4fffb5e4e73a800b5c4ad4fc0be5def634d2e1", size = 181062 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/93/282b5f4898d8e8efaf0790ba6d10e2245d2c9f30e199d1a85cae9356098c/yarl-1.18.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8503ad47387b8ebd39cbbbdf0bf113e17330ffd339ba1144074da24c545f0069", size = 141555 }, + { url = "https://files.pythonhosted.org/packages/6d/9c/0a49af78df099c283ca3444560f10718fadb8a18dc8b3edf8c7bd9fd7d89/yarl-1.18.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:02ddb6756f8f4517a2d5e99d8b2f272488e18dd0bfbc802f31c16c6c20f22193", size = 94351 }, + { url = "https://files.pythonhosted.org/packages/5a/a1/205ab51e148fdcedad189ca8dd587794c6f119882437d04c33c01a75dece/yarl-1.18.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:67a283dd2882ac98cc6318384f565bffc751ab564605959df4752d42483ad889", size = 92286 }, + { url = "https://files.pythonhosted.org/packages/ed/fe/88b690b30f3f59275fb674f5f93ddd4a3ae796c2b62e5bb9ece8a4914b83/yarl-1.18.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d980e0325b6eddc81331d3f4551e2a333999fb176fd153e075c6d1c2530aa8a8", size = 340649 }, + { url = "https://files.pythonhosted.org/packages/07/eb/3b65499b568e01f36e847cebdc8d7ccb51fff716dbda1ae83c3cbb8ca1c9/yarl-1.18.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b643562c12680b01e17239be267bc306bbc6aac1f34f6444d1bded0c5ce438ca", size = 356623 }, + { url = "https://files.pythonhosted.org/packages/33/46/f559dc184280b745fc76ec6b1954de2c55595f0ec0a7614238b9ebf69618/yarl-1.18.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c017a3b6df3a1bd45b9fa49a0f54005e53fbcad16633870104b66fa1a30a29d8", size = 354007 }, + { url = "https://files.pythonhosted.org/packages/af/ba/1865d85212351ad160f19fb99808acf23aab9a0f8ff31c8c9f1b4d671fc9/yarl-1.18.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75674776d96d7b851b6498f17824ba17849d790a44d282929c42dbb77d4f17ae", size = 344145 }, + { url = "https://files.pythonhosted.org/packages/94/cb/5c3e975d77755d7b3d5193e92056b19d83752ea2da7ab394e22260a7b824/yarl-1.18.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ccaa3a4b521b780a7e771cc336a2dba389a0861592bbce09a476190bb0c8b4b3", size = 336133 }, + { url = "https://files.pythonhosted.org/packages/19/89/b77d3fd249ab52a5c40859815765d35c91425b6bb82e7427ab2f78f5ff55/yarl-1.18.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2d06d3005e668744e11ed80812e61efd77d70bb7f03e33c1598c301eea20efbb", size = 347967 }, + { url = "https://files.pythonhosted.org/packages/35/bd/f6b7630ba2cc06c319c3235634c582a6ab014d52311e7d7c22f9518189b5/yarl-1.18.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:9d41beda9dc97ca9ab0b9888cb71f7539124bc05df02c0cff6e5acc5a19dcc6e", size = 346397 }, + { url = "https://files.pythonhosted.org/packages/18/1a/0b4e367d5a72d1f095318344848e93ea70da728118221f84f1bf6c1e39e7/yarl-1.18.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ba23302c0c61a9999784e73809427c9dbedd79f66a13d84ad1b1943802eaaf59", size = 350206 }, + { url = "https://files.pythonhosted.org/packages/b5/cf/320fff4367341fb77809a2d8d7fe75b5d323a8e1b35710aafe41fdbf327b/yarl-1.18.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6748dbf9bfa5ba1afcc7556b71cda0d7ce5f24768043a02a58846e4a443d808d", size = 362089 }, + { url = "https://files.pythonhosted.org/packages/57/cf/aadba261d8b920253204085268bad5e8cdd86b50162fcb1b10c10834885a/yarl-1.18.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0b0cad37311123211dc91eadcb322ef4d4a66008d3e1bdc404808992260e1a0e", size = 366267 }, + { url = "https://files.pythonhosted.org/packages/54/58/fb4cadd81acdee6dafe14abeb258f876e4dd410518099ae9a35c88d8097c/yarl-1.18.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0fb2171a4486bb075316ee754c6d8382ea6eb8b399d4ec62fde2b591f879778a", size = 359141 }, + { url = "https://files.pythonhosted.org/packages/9a/7a/4c571597589da4cd5c14ed2a0b17ac56ec9ee7ee615013f74653169e702d/yarl-1.18.3-cp311-cp311-win32.whl", hash = "sha256:61b1a825a13bef4a5f10b1885245377d3cd0bf87cba068e1d9a88c2ae36880e1", size = 84402 }, + { url = "https://files.pythonhosted.org/packages/ae/7b/8600250b3d89b625f1121d897062f629883c2f45339623b69b1747ec65fa/yarl-1.18.3-cp311-cp311-win_amd64.whl", hash = "sha256:b9d60031cf568c627d028239693fd718025719c02c9f55df0a53e587aab951b5", size = 91030 }, + { url = "https://files.pythonhosted.org/packages/33/85/bd2e2729752ff4c77338e0102914897512e92496375e079ce0150a6dc306/yarl-1.18.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1dd4bdd05407ced96fed3d7f25dbbf88d2ffb045a0db60dbc247f5b3c5c25d50", size = 142644 }, + { url = "https://files.pythonhosted.org/packages/ff/74/1178322cc0f10288d7eefa6e4a85d8d2e28187ccab13d5b844e8b5d7c88d/yarl-1.18.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7c33dd1931a95e5d9a772d0ac5e44cac8957eaf58e3c8da8c1414de7dd27c576", size = 94962 }, + { url = "https://files.pythonhosted.org/packages/be/75/79c6acc0261e2c2ae8a1c41cf12265e91628c8c58ae91f5ff59e29c0787f/yarl-1.18.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25b411eddcfd56a2f0cd6a384e9f4f7aa3efee14b188de13048c25b5e91f1640", size = 92795 }, + { url = "https://files.pythonhosted.org/packages/6b/32/927b2d67a412c31199e83fefdce6e645247b4fb164aa1ecb35a0f9eb2058/yarl-1.18.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:436c4fc0a4d66b2badc6c5fc5ef4e47bb10e4fd9bf0c79524ac719a01f3607c2", size = 332368 }, + { url = "https://files.pythonhosted.org/packages/19/e5/859fca07169d6eceeaa4fde1997c91d8abde4e9a7c018e371640c2da2b71/yarl-1.18.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e35ef8683211db69ffe129a25d5634319a677570ab6b2eba4afa860f54eeaf75", size = 342314 }, + { url = "https://files.pythonhosted.org/packages/08/75/76b63ccd91c9e03ab213ef27ae6add2e3400e77e5cdddf8ed2dbc36e3f21/yarl-1.18.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:84b2deecba4a3f1a398df819151eb72d29bfeb3b69abb145a00ddc8d30094512", size = 341987 }, + { url = "https://files.pythonhosted.org/packages/1a/e1/a097d5755d3ea8479a42856f51d97eeff7a3a7160593332d98f2709b3580/yarl-1.18.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00e5a1fea0fd4f5bfa7440a47eff01d9822a65b4488f7cff83155a0f31a2ecba", size = 336914 }, + { url = "https://files.pythonhosted.org/packages/0b/42/e1b4d0e396b7987feceebe565286c27bc085bf07d61a59508cdaf2d45e63/yarl-1.18.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d0e883008013c0e4aef84dcfe2a0b172c4d23c2669412cf5b3371003941f72bb", size = 325765 }, + { url = "https://files.pythonhosted.org/packages/7e/18/03a5834ccc9177f97ca1bbb245b93c13e58e8225276f01eedc4cc98ab820/yarl-1.18.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5a3f356548e34a70b0172d8890006c37be92995f62d95a07b4a42e90fba54272", size = 344444 }, + { url = "https://files.pythonhosted.org/packages/c8/03/a713633bdde0640b0472aa197b5b86e90fbc4c5bc05b727b714cd8a40e6d/yarl-1.18.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ccd17349166b1bee6e529b4add61727d3f55edb7babbe4069b5764c9587a8cc6", size = 340760 }, + { url = "https://files.pythonhosted.org/packages/eb/99/f6567e3f3bbad8fd101886ea0276c68ecb86a2b58be0f64077396cd4b95e/yarl-1.18.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b958ddd075ddba5b09bb0be8a6d9906d2ce933aee81100db289badbeb966f54e", size = 346484 }, + { url = "https://files.pythonhosted.org/packages/8e/a9/84717c896b2fc6cb15bd4eecd64e34a2f0a9fd6669e69170c73a8b46795a/yarl-1.18.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c7d79f7d9aabd6011004e33b22bc13056a3e3fb54794d138af57f5ee9d9032cb", size = 359864 }, + { url = "https://files.pythonhosted.org/packages/1e/2e/d0f5f1bef7ee93ed17e739ec8dbcb47794af891f7d165fa6014517b48169/yarl-1.18.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4891ed92157e5430874dad17b15eb1fda57627710756c27422200c52d8a4e393", size = 364537 }, + { url = "https://files.pythonhosted.org/packages/97/8a/568d07c5d4964da5b02621a517532adb8ec5ba181ad1687191fffeda0ab6/yarl-1.18.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ce1af883b94304f493698b00d0f006d56aea98aeb49d75ec7d98cd4a777e9285", size = 357861 }, + { url = "https://files.pythonhosted.org/packages/7d/e3/924c3f64b6b3077889df9a1ece1ed8947e7b61b0a933f2ec93041990a677/yarl-1.18.3-cp312-cp312-win32.whl", hash = "sha256:f91c4803173928a25e1a55b943c81f55b8872f0018be83e3ad4938adffb77dd2", size = 84097 }, + { url = "https://files.pythonhosted.org/packages/34/45/0e055320daaabfc169b21ff6174567b2c910c45617b0d79c68d7ab349b02/yarl-1.18.3-cp312-cp312-win_amd64.whl", hash = "sha256:7e2ee16578af3b52ac2f334c3b1f92262f47e02cc6193c598502bd46f5cd1477", size = 90399 }, + { url = "https://files.pythonhosted.org/packages/30/c7/c790513d5328a8390be8f47be5d52e141f78b66c6c48f48d241ca6bd5265/yarl-1.18.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:90adb47ad432332d4f0bc28f83a5963f426ce9a1a8809f5e584e704b82685dcb", size = 140789 }, + { url = "https://files.pythonhosted.org/packages/30/aa/a2f84e93554a578463e2edaaf2300faa61c8701f0898725842c704ba5444/yarl-1.18.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:913829534200eb0f789d45349e55203a091f45c37a2674678744ae52fae23efa", size = 94144 }, + { url = "https://files.pythonhosted.org/packages/c6/fc/d68d8f83714b221a85ce7866832cba36d7c04a68fa6a960b908c2c84f325/yarl-1.18.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ef9f7768395923c3039055c14334ba4d926f3baf7b776c923c93d80195624782", size = 91974 }, + { url = "https://files.pythonhosted.org/packages/56/4e/d2563d8323a7e9a414b5b25341b3942af5902a2263d36d20fb17c40411e2/yarl-1.18.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88a19f62ff30117e706ebc9090b8ecc79aeb77d0b1f5ec10d2d27a12bc9f66d0", size = 333587 }, + { url = "https://files.pythonhosted.org/packages/25/c9/cfec0bc0cac8d054be223e9f2c7909d3e8442a856af9dbce7e3442a8ec8d/yarl-1.18.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e17c9361d46a4d5addf777c6dd5eab0715a7684c2f11b88c67ac37edfba6c482", size = 344386 }, + { url = "https://files.pythonhosted.org/packages/ab/5d/4c532190113b25f1364d25f4c319322e86232d69175b91f27e3ebc2caf9a/yarl-1.18.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a74a13a4c857a84a845505fd2d68e54826a2cd01935a96efb1e9d86c728e186", size = 345421 }, + { url = "https://files.pythonhosted.org/packages/23/d1/6cdd1632da013aa6ba18cee4d750d953104a5e7aac44e249d9410a972bf5/yarl-1.18.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41f7ce59d6ee7741af71d82020346af364949314ed3d87553763a2df1829cc58", size = 339384 }, + { url = "https://files.pythonhosted.org/packages/9a/c4/6b3c39bec352e441bd30f432cda6ba51681ab19bb8abe023f0d19777aad1/yarl-1.18.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f52a265001d830bc425f82ca9eabda94a64a4d753b07d623a9f2863fde532b53", size = 326689 }, + { url = "https://files.pythonhosted.org/packages/23/30/07fb088f2eefdc0aa4fc1af4e3ca4eb1a3aadd1ce7d866d74c0f124e6a85/yarl-1.18.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:82123d0c954dc58db301f5021a01854a85bf1f3bb7d12ae0c01afc414a882ca2", size = 345453 }, + { url = "https://files.pythonhosted.org/packages/63/09/d54befb48f9cd8eec43797f624ec37783a0266855f4930a91e3d5c7717f8/yarl-1.18.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2ec9bbba33b2d00999af4631a3397d1fd78290c48e2a3e52d8dd72db3a067ac8", size = 341872 }, + { url = "https://files.pythonhosted.org/packages/91/26/fd0ef9bf29dd906a84b59f0cd1281e65b0c3e08c6aa94b57f7d11f593518/yarl-1.18.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fbd6748e8ab9b41171bb95c6142faf068f5ef1511935a0aa07025438dd9a9bc1", size = 347497 }, + { url = "https://files.pythonhosted.org/packages/d9/b5/14ac7a256d0511b2ac168d50d4b7d744aea1c1aa20c79f620d1059aab8b2/yarl-1.18.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:877d209b6aebeb5b16c42cbb377f5f94d9e556626b1bfff66d7b0d115be88d0a", size = 359981 }, + { url = "https://files.pythonhosted.org/packages/ca/b3/d493221ad5cbd18bc07e642894030437e405e1413c4236dd5db6e46bcec9/yarl-1.18.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b464c4ab4bfcb41e3bfd3f1c26600d038376c2de3297760dfe064d2cb7ea8e10", size = 366229 }, + { url = "https://files.pythonhosted.org/packages/04/56/6a3e2a5d9152c56c346df9b8fb8edd2c8888b1e03f96324d457e5cf06d34/yarl-1.18.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8d39d351e7faf01483cc7ff7c0213c412e38e5a340238826be7e0e4da450fdc8", size = 360383 }, + { url = "https://files.pythonhosted.org/packages/fd/b7/4b3c7c7913a278d445cc6284e59b2e62fa25e72758f888b7a7a39eb8423f/yarl-1.18.3-cp313-cp313-win32.whl", hash = "sha256:61ee62ead9b68b9123ec24bc866cbef297dd266175d53296e2db5e7f797f902d", size = 310152 }, + { url = "https://files.pythonhosted.org/packages/f5/d5/688db678e987c3e0fb17867970700b92603cadf36c56e5fb08f23e822a0c/yarl-1.18.3-cp313-cp313-win_amd64.whl", hash = "sha256:578e281c393af575879990861823ef19d66e2b1d0098414855dd367e234f5b3c", size = 315723 }, + { url = "https://files.pythonhosted.org/packages/f5/4b/a06e0ec3d155924f77835ed2d167ebd3b211a7b0853da1cf8d8414d784ef/yarl-1.18.3-py3-none-any.whl", hash = "sha256:b57f4f58099328dfb26c6a771d09fb20dbbae81d20cfb66141251ea063bd101b", size = 45109 }, +]