diff --git a/.gitattributes b/.gitattributes index f1815500..581a1cb4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,4 @@ *.sh text eol=lf +*.json text eol=lf +*.md text eol=lf +*.rst text eol=lf diff --git a/.github/actions/setup/action.yaml b/.github/actions/setup/action.yaml new file mode 100644 index 00000000..490adaef --- /dev/null +++ b/.github/actions/setup/action.yaml @@ -0,0 +1,49 @@ +--- +name: Setup Environment +description: Install uv, configure the system python, and the package dependencies + +inputs: + uv-install-options: + default: "" + uv-version: + default: 0.4.16 + python-version: + required: true + cache-pre-commit: + default: false + cache-version: + default: "v0.1" + +runs: + using: composite + steps: + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + + - name: "Setup python" + uses: "actions/setup-python@v5" + id: setup-python + with: + python-version: "${{ inputs.python-version }}" + allow-prereleases: true + + - name: "Install project" + shell: bash + run: | + uv sync ${{ inputs.uv-install-options }} + + - name: Read pre-commit version + if: inputs.cache-pre-commit == 'true' + id: pre-commit-version + shell: bash + run: >- + echo "pre-commit-version=$(uv run pre-commit -V | awk '{print $2}')" >> $GITHUB_OUTPUT + + - uses: actions/cache@v4 + if: inputs.cache-pre-commit == 'true' + name: Pre-commit cache + 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 index 1dcd091e..c8c145cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,11 +2,13 @@ name: CI on: push: - branches: ["master"] + branches: ["master", "patch"] pull_request: - branches: ["master"] + branches: ["master", "patch"] workflow_dispatch: # to allow manual re-runs +env: + UV_VERSION: 0.4.16 jobs: linting: @@ -15,38 +17,23 @@ jobs: strategy: matrix: - python-version: ["3.12"] + python-version: ["3.13"] steps: - - uses: "actions/checkout@v2" - - uses: "actions/setup-python@v2" + - name: "Checkout source files" + uses: "actions/checkout@v4" + - name: Setup environment + uses: ./.github/actions/setup with: - python-version: "${{ matrix.python-version }}" - - name: "Install dependencies" + python-version: ${{ matrix.python-version }} + cache-pre-commit: true + uv-version: ${{ env.UV_VERSION }} + uv-install-options: "--all-extras" + + - name: "Run pre-commit checks" run: | - python -m pip install --upgrade pip poetry - poetry install - - name: "Linting and code formating (ruff)" - run: | - poetry run pre-commit run ruff --all-files - - name: "Typing checks (mypy)" - run: | - poetry run pre-commit run mypy --all-files - - name: "Run trailing-whitespace" - run: | - poetry run pre-commit run trailing-whitespace --all-files - - name: "Run end-of-file-fixer" - run: | - poetry run pre-commit run end-of-file-fixer --all-files - - name: "Run check-docstring-first" - run: | - poetry run pre-commit run check-docstring-first --all-files - - name: "Run debug-statements" - run: | - poetry run pre-commit run debug-statements --all-files - - name: "Run check-ast" - run: | - poetry run pre-commit run check-ast --all-files + uv run pre-commit run --all-files --verbose + tests: name: Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} @@ -55,7 +42,7 @@ jobs: strategy: matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "pypy-3.8", "pypy-3.10"] + python-version: ["3.11", "3.12", "3.13"] os: [ubuntu-latest, macos-latest, windows-latest] extras: [false, true] exclude: @@ -63,41 +50,19 @@ jobs: extras: true - os: windows-latest extras: true - - os: ubuntu-latest - python-version: "pypy-3.8" - extras: true - - os: ubuntu-latest - python-version: "pypy-3.10" - extras: true - - os: ubuntu-latest - python-version: "3.8" - extras: true - - os: ubuntu-latest - python-version: "3.9" - extras: true - - os: ubuntu-latest - python-version: "3.10" - extras: true steps: - - uses: "actions/checkout@v3" - - uses: "actions/setup-python@v4" + - uses: "actions/checkout@v4" + - name: Setup environment + uses: ./.github/actions/setup with: - python-version: "${{ matrix.python-version }}" - - name: "Install dependencies (no speedups)" - if: matrix.extras == false + python-version: ${{ matrix.python-version }} + uv-version: ${{ env.UV_VERSION }} + uv-install-options: ${{ matrix.extras == true && '--all-extras' || '' }} + - name: "Run tests (with coverage)" run: | - python -m pip install --upgrade pip poetry - poetry install - - name: "Install dependencies (with speedups)" - if: matrix.extras == true - run: | - python -m pip install --upgrade pip poetry - poetry install --extras speedups - - name: "Run tests" - run: | - poetry run pytest --cov kasa --cov-report xml + uv run pytest -n auto --cov kasa --cov-report xml - name: "Upload coverage to Codecov" - uses: "codecov/codecov-action@v3" + uses: "codecov/codecov-action@v4" with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index b8d5f396..29d53358 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -2,9 +2,9 @@ name: "CodeQL checks" on: push: - branches: [ master ] + branches: [ "master", "patch" ] pull_request: - branches: [ master ] + branches: [ master, "patch" ] schedule: - cron: '44 17 * * 3' diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e48066bb..9c1837f0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -3,6 +3,10 @@ on: release: types: [published] +env: + UV_VERSION: 0.4.16 + PYTHON_VERSION: 3.12 + jobs: build-n-publish: name: Build release packages @@ -11,25 +15,19 @@ jobs: id-token: write steps: - - uses: actions/checkout@master + - name: Checkout source files + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + - name: Setup python uses: actions/setup-python@v4 with: - python-version: "3.x" + python-version: ${{ env.PYTHON_VERSION }} - - name: Install pypa/build - run: >- - python -m - pip install - build - --user - name: Build a binary wheel and a source tarball - run: >- - python -m - build - --sdist - --wheel - --outdir dist/ - . + run: uv build + - name: 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 00000000..294b4e1f --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,69 @@ +name: Stale + +# yamllint disable-line rule:truthy +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 + uses: actions/stale@v9.0.0 + 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 + uses: actions/stale@v9.0.0 + 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 index 0341d408..538e6ec1 100644 --- a/.github_changelog_generator +++ b/.github_changelog_generator @@ -1,4 +1,11 @@ -breaking_labels=breaking change -add-sections={"docs":{"prefix":"**Documentation updates:**","labels":["documentation"]}} -release_branch=master +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/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4bbfd8c5..adcad8e4 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.4.16 + hooks: + # Update the uv lockfile + - id: uv-lock + - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.6.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -10,20 +18,36 @@ repos: - id: check-ast - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.3 + rev: v0.7.4 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - id: ruff-format -- repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.3.0 - hooks: - - id: mypy - additional_dependencies: [types-click] - - repo: https://github.com/PyCQA/doc8 rev: 'v1.1.1' hooks: - id: doc8 additional_dependencies: [tomli] + +- 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 + 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 e79a0598..1d01cf18 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -6,6 +6,9 @@ build: os: ubuntu-22.04 tools: python: "3" + jobs: + pre_build: + - python -m sphinx -b linkcheck docs/source/ $READTHEDOCS_OUTPUT/linkcheck python: install: diff --git a/CHANGELOG.md b/CHANGELOG.md index d2100da3..2ef0873f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,54 +1,873 @@ # Changelog -## [0.6.0.dev2](https://github.com/python-kasa/python-kasa/tree/0.6.0.dev2) (2024-01-11) +## [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.6.0.dev1...0.6.0.dev2) +This patch release fixes some issues with newly supported smartcam devices. -**Documentation updates:** - -- Update docs for newer devices and DeviceConfig [\#614](https://github.com/python-kasa/python-kasa/pull/614) (@sdb9696) - -**Merged pull requests:** - -- 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) - -## [0.6.0.dev1](https://github.com/python-kasa/python-kasa/tree/0.6.0.dev1) (2024-01-05) - -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.0.dev0...0.6.0.dev1) - -**Implemented enhancements:** - -- 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) +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.8.0...0.8.1) **Fixed bugs:** -- 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 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) +- Move TAPO smartcamera out of experimental package [\#1255](https://github.com/python-kasa/python-kasa/pull/1255) (@sdb9696) +- Add SmartCamera Led Module [\#1249](https://github.com/python-kasa/python-kasa/pull/1249) (@sdb9696) +- Use component queries to select smartcamera modules [\#1248](https://github.com/python-kasa/python-kasa/pull/1248) (@sdb9696) +- Print formatting for IotLightPreset [\#1216](https://github.com/python-kasa/python-kasa/pull/1216) (@Puxtril) +- Allow getting Annotated features from modules [\#1018](https://github.com/python-kasa/python-kasa/pull/1018) (@sdb9696) +- Add common Thermostat module [\#977](https://github.com/python-kasa/python-kasa/pull/977) (@sdb9696) + +**Fixed bugs:** + +- TP-Link Tapo S505D cannot disable gradual on/off [\#1309](https://github.com/python-kasa/python-kasa/issues/1309) +- Inconsistent emeter information between features and emeter cli [\#1308](https://github.com/python-kasa/python-kasa/issues/1308) +- How to dump power usage after latest updates? [\#1306](https://github.com/python-kasa/python-kasa/issues/1306) +- kasa.discover: Got unsupported connection type: 'device\_family': 'SMART.IPCAMERA' [\#1267](https://github.com/python-kasa/python-kasa/issues/1267) +- device \_\_repr\_\_ fails if no sys\_info [\#1262](https://github.com/python-kasa/python-kasa/issues/1262) +- Tapo P110M: Error processing Energy for device, module will be unavailable: get\_energy\_usage for Energy [\#1243](https://github.com/python-kasa/python-kasa/issues/1243) +- Listing light presets throws error [\#1201](https://github.com/python-kasa/python-kasa/issues/1201) +- Include duration when disabling smooth transition on/off [\#1313](https://github.com/python-kasa/python-kasa/pull/1313) (@rytilahti) +- Expose energy command to cli [\#1307](https://github.com/python-kasa/python-kasa/pull/1307) (@rytilahti) +- 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) + +**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) +- Update docs for the new module attributes has/get feature [\#1301](https://github.com/python-kasa/python-kasa/pull/1301) (@sdb9696) +- Fixup contributing.md for running test against a real device [\#1236](https://github.com/python-kasa/python-kasa/pull/1236) (@sdb9696) + +**Project maintenance:** + +- Rename tests/smartcamera to tests/smartcam [\#1315](https://github.com/python-kasa/python-kasa/pull/1315) (@sdb9696) +- Do not error on smartcam hub attached smartcam child devices [\#1314](https://github.com/python-kasa/python-kasa/pull/1314) (@sdb9696) +- Add P110M\(EU\) fixture [\#1305](https://github.com/python-kasa/python-kasa/pull/1305) (@sdb9696) +- Run tests with caplog in a single worker [\#1304](https://github.com/python-kasa/python-kasa/pull/1304) (@sdb9696) +- Rename smartcamera to smartcam [\#1300](https://github.com/python-kasa/python-kasa/pull/1300) (@sdb9696) +- 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) +- Update smartcamera fixtures with components [\#1250](https://github.com/python-kasa/python-kasa/pull/1250) (@sdb9696) +- Move transports into their own package [\#1247](https://github.com/python-kasa/python-kasa/pull/1247) (@rytilahti) +- Fix warnings in our test suite [\#1246](https://github.com/python-kasa/python-kasa/pull/1246) (@rytilahti) +- Move tests folder to top level of project [\#1242](https://github.com/python-kasa/python-kasa/pull/1242) (@sdb9696) +- Fix test framework running against real devices [\#1235](https://github.com/python-kasa/python-kasa/pull/1235) (@sdb9696) +- Add Additional Firmware Test Fixures [\#1234](https://github.com/python-kasa/python-kasa/pull/1234) (@ryenitcher) +- Update DiscoveryResult to use Mashumaro instead of pydantic [\#1231](https://github.com/python-kasa/python-kasa/pull/1231) (@sdb9696) +- Update fixture for ES20M 1.0.11 [\#1215](https://github.com/python-kasa/python-kasa/pull/1215) (@rytilahti) +- Enable ruff check for ANN [\#1139](https://github.com/python-kasa/python-kasa/pull/1139) (@rytilahti) **Closed issues:** -- Need to do error code checking for new protocols [\#612](https://github.com/python-kasa/python-kasa/issues/612) -- Support of last firmware update version 1.3.0 [\#611](https://github.com/python-kasa/python-kasa/issues/611) -- Implement energy and usage for individual plugs in HS300 [\#462](https://github.com/python-kasa/python-kasa/issues/462) +- 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:** -- Release 0.6.0.dev1 [\#624](https://github.com/python-kasa/python-kasa/pull/624) (@rytilahti) -- 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) +- Remove frost\_protection feature [\#1009](https://github.com/python-kasa/python-kasa/pull/1009) (@rytilahti) -## [0.6.0.dev0](https://github.com/python-kasa/python-kasa/tree/0.6.0.dev0) (2024-01-03) +## [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.5.4...0.6.0.dev0) +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.2.1...0.7.0) + +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. + +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:** + +- 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:** + +- 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:** + +- 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:** @@ -60,9 +879,9 @@ - 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) -- 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) +- 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) @@ -72,48 +891,58 @@ - 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 support for tapo bulbs [\#558](https://github.com/python-kasa/python-kasa/pull/558) (@rytilahti) - 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) -**Closed issues:** - -- Discover returns dictionary with no 'alias' property [\#592](https://github.com/python-kasa/python-kasa/issues/592) -- Sending with the legacy protocol is needlessly delayed [\#553](https://github.com/python-kasa/python-kasa/issues/553) -- Issues adding a KP405 device [\#549](https://github.com/python-kasa/python-kasa/issues/549) -- Support for L510E bulb [\#547](https://github.com/python-kasa/python-kasa/issues/547) -- Support for tapo L530E bulbs? [\#546](https://github.com/python-kasa/python-kasa/issues/546) -- Unable to connect to host on different subnet with 0.5.4 [\#545](https://github.com/python-kasa/python-kasa/issues/545) -- Discovery/Connect broken when upgrading from 0.5.3 -\> 0.5.4 [\#543](https://github.com/python-kasa/python-kasa/issues/543) -- PydanticUserError, If you use `@root_validator` with pre=False \(the default\) you MUST specify `skip_on_failure=True` [\#516](https://github.com/python-kasa/python-kasa/issues/516) -- KP 125M / support for matter devices [\#450](https://github.com/python-kasa/python-kasa/issues/450) - **Merged pull requests:** -- Release 0.6.0.dev0 [\#609](https://github.com/python-kasa/python-kasa/pull/609) (@rytilahti) +- 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) -- Add known smart requests to dump\_devinfo [\#597](https://github.com/python-kasa/python-kasa/pull/597) (@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) @@ -126,18 +955,19 @@ - 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. - +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:** @@ -164,24 +994,8 @@ As always, see the full changelog for details. - 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) -**Closed issues:** - -- Error running kasa command on the Raspberry PI [\#525](https://github.com/python-kasa/python-kasa/issues/525) -- Installation Problems \(Python Version?\) [\#523](https://github.com/python-kasa/python-kasa/issues/523) -- What are the units in the emeter readings? [\#514](https://github.com/python-kasa/python-kasa/issues/514) -- Set Alias via Command Line [\#511](https://github.com/python-kasa/python-kasa/issues/511) -- How do I know if my device supports emeter? [\#510](https://github.com/python-kasa/python-kasa/issues/510) -- Getting Invalid KeyError when getting sysinfo on an EP40 device [\#500](https://github.com/python-kasa/python-kasa/issues/500) -- Running kasa discover on subnet broadcasts only [\#496](https://github.com/python-kasa/python-kasa/issues/496) -- Failed to discover kasa switchs on the network [\#495](https://github.com/python-kasa/python-kasa/issues/495) -- \[Feature Request\] Add a toggle command [\#492](https://github.com/python-kasa/python-kasa/issues/492) -- \[Feature Request\] Pydantic 2.0+ Support [\#491](https://github.com/python-kasa/python-kasa/issues/491) -- Support for EP10 Plug [\#170](https://github.com/python-kasa/python-kasa/issues/170) -- \[Request\] New release to pip? [\#518](https://github.com/python-kasa/python-kasa/issues/518) - **Merged pull requests:** -- Release 0.5.4 [\#536](https://github.com/python-kasa/python-kasa/pull/536) (@rytilahti) - 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) @@ -206,7 +1020,6 @@ This release adds support for defining the device port and introduces dependency **Merged pull requests:** -- Release 0.5.3 [\#485](https://github.com/python-kasa/python-kasa/pull/485) (@rytilahti) - 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) @@ -214,8 +1027,8 @@ This release adds support for defining the device port and introduces dependency [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. +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:** @@ -238,20 +1051,8 @@ Besides some small improvements, this release: - 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) -**Closed issues:** - -- Powershell version? [\#461](https://github.com/python-kasa/python-kasa/issues/461) -- Add `set_cold_time` to Motion module [\#452](https://github.com/python-kasa/python-kasa/issues/452) -- Discover.discover\(\) only returning ip adress on ep10 outlet [\#447](https://github.com/python-kasa/python-kasa/issues/447) -- Query current wifi config? [\#445](https://github.com/python-kasa/python-kasa/issues/445) -- bulb.turn\_off making device undiscoverable [\#444](https://github.com/python-kasa/python-kasa/issues/444) -- best privacy practices for Kasa devices [\#438](https://github.com/python-kasa/python-kasa/issues/438) -- Access device from different network [\#424](https://github.com/python-kasa/python-kasa/issues/424) -- Lots of test failure with 0.5.0 [\#411](https://github.com/python-kasa/python-kasa/issues/411) - **Merged pull requests:** -- Release 0.5.2 [\#475](https://github.com/python-kasa/python-kasa/pull/475) (@rytilahti) - 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) @@ -262,11 +1063,11 @@ Besides some small improvements, this release: [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 +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:** @@ -306,35 +1107,10 @@ This minor release contains mostly small UX fine-tuning and documentation improv **Closed issues:** -- detecting when a switch changes state [\#427](https://github.com/python-kasa/python-kasa/issues/427) -- discovery fails for aliases [\#426](https://github.com/python-kasa/python-kasa/issues/426) -- traceback when no devices exist [\#425](https://github.com/python-kasa/python-kasa/issues/425) -- Discover.discover\(\) in a cron that runs every 1 min [\#421](https://github.com/python-kasa/python-kasa/issues/421) -- add Schedule rule? [\#418](https://github.com/python-kasa/python-kasa/issues/418) -- Cannot find EP10 using kasa discover [\#417](https://github.com/python-kasa/python-kasa/issues/417) -- modulenotfound error [\#414](https://github.com/python-kasa/python-kasa/issues/414) -- Issue enabling motion sensor, ES20M\(US\) [\#408](https://github.com/python-kasa/python-kasa/issues/408) -- HS103 not discovered by kasa CLI [\#406](https://github.com/python-kasa/python-kasa/issues/406) -- Multiple warnings from running pytest due to asyncio issues [\#396](https://github.com/python-kasa/python-kasa/issues/396) -- Transition ignored with KL420L5 light strips [\#389](https://github.com/python-kasa/python-kasa/issues/389) -- cli.py passes a dictionary \(TYPE\_TO\_CLASS\) to click.Choice which takes a Sequence\[str\] [\#384](https://github.com/python-kasa/python-kasa/issues/384) -- Error running `kasa wifi scan` [\#376](https://github.com/python-kasa/python-kasa/issues/376) -- Unable to connect to brand new EP40 v1.8 [\#366](https://github.com/python-kasa/python-kasa/issues/366) - 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) -- Set bulb hue using variable [\#361](https://github.com/python-kasa/python-kasa/issues/361) -- Help with SmartLightStrip set\_custom\_effect [\#360](https://github.com/python-kasa/python-kasa/issues/360) -- Import "kasa" could not be resolved [\#357](https://github.com/python-kasa/python-kasa/issues/357) -- Wall switch ES20M \(--type dimmer\) is working [\#353](https://github.com/python-kasa/python-kasa/issues/353) -- HS107 reports `state` not `relay_state` throwing a `KeyError` [\#349](https://github.com/python-kasa/python-kasa/issues/349) -- Error Installing On Windows 10 [\#347](https://github.com/python-kasa/python-kasa/issues/347) -- Error using Kasa [\#346](https://github.com/python-kasa/python-kasa/issues/346) -- KS220M\(US\) support [\#268](https://github.com/python-kasa/python-kasa/issues/268) -- Add machine-readable output [\#209](https://github.com/python-kasa/python-kasa/issues/209) -- Can we donate? [\#77](https://github.com/python-kasa/python-kasa/issues/77) **Merged pull requests:** -- Prepare 0.5.1 [\#434](https://github.com/python-kasa/python-kasa/pull/434) (@rytilahti) - 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) @@ -355,21 +1131,21 @@ This minor release contains mostly small UX fine-tuning and documentation improv [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. +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:** @@ -395,19 +1171,8 @@ Pull requests improving the functionality of modules as well as adding better in - 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) -**Closed issues:** - -- "on since" changes [\#295](https://github.com/python-kasa/python-kasa/issues/295) -- How to access KP115 runtime data? [\#244](https://github.com/python-kasa/python-kasa/issues/244) -- How to resolve "Detected protocol reuse between different event loop" warning? [\#238](https://github.com/python-kasa/python-kasa/issues/238) -- Handle discovery where multiple LAN interfaces exist [\#104](https://github.com/python-kasa/python-kasa/issues/104) -- Hyper-V \(and probably virtualbox\) break UDP discovery [\#101](https://github.com/python-kasa/python-kasa/issues/101) -- Trying to get extended lightstrip functionality [\#100](https://github.com/python-kasa/python-kasa/issues/100) -- Can the HS105 be controlled without internet? [\#72](https://github.com/python-kasa/python-kasa/issues/72) - **Merged pull requests:** -- Prepare 0.5.0 [\#342](https://github.com/python-kasa/python-kasa/pull/342) (@rytilahti) - 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) @@ -419,13 +1184,8 @@ Pull requests improving the functionality of modules as well as adding better in - 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) -**Closed issues:** - -- KL420L5 controls [\#327](https://github.com/python-kasa/python-kasa/issues/327) - **Merged pull requests:** -- Release 0.4.3 [\#332](https://github.com/python-kasa/python-kasa/pull/332) (@rytilahti) - 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) @@ -446,20 +1206,8 @@ Pull requests improving the functionality of modules as well as adding better in - 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) -**Closed issues:** - -- SmartDeviceException: Communication error on system:set\_relay\_state [\#309](https://github.com/python-kasa/python-kasa/issues/309) -- Add Support: ES20M and KS200M motion/light switches [\#308](https://github.com/python-kasa/python-kasa/issues/308) -- New problem with installing on Ubuntu 20.04.3 LTS [\#305](https://github.com/python-kasa/python-kasa/issues/305) -- KeyError: 'emeter' when discovering [\#302](https://github.com/python-kasa/python-kasa/issues/302) -- RuntimeError: Event loop is closed [\#291](https://github.com/python-kasa/python-kasa/issues/291) -- provisioning format [\#290](https://github.com/python-kasa/python-kasa/issues/290) -- Fix CI publishing on pypi [\#222](https://github.com/python-kasa/python-kasa/issues/222) -- LED strips effects are not supported \(was LEDs is not turning on after switching on\) [\#191](https://github.com/python-kasa/python-kasa/issues/191) - **Merged pull requests:** -- Release 0.4.2 [\#321](https://github.com/python-kasa/python-kasa/pull/321) (@rytilahti) - 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) @@ -496,26 +1244,8 @@ Pull requests improving the functionality of modules as well as adding better in - 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) -**Closed issues:** - -- Control device with alias via python api? [\#285](https://github.com/python-kasa/python-kasa/issues/285) -- Can't install using pip install python-kasa [\#255](https://github.com/python-kasa/python-kasa/issues/255) -- Kasa Smart Bulb KL135 - Unknown color temperature range error [\#252](https://github.com/python-kasa/python-kasa/issues/252) -- KL400 Support [\#247](https://github.com/python-kasa/python-kasa/issues/247) -- Cloud support? [\#245](https://github.com/python-kasa/python-kasa/issues/245) -- Support for kp401 [\#241](https://github.com/python-kasa/python-kasa/issues/241) -- LB130 Bulb stopped working [\#237](https://github.com/python-kasa/python-kasa/issues/237) -- Unable to constantly query bulb in loop [\#225](https://github.com/python-kasa/python-kasa/issues/225) -- HS103: Unable to query the device: unpack requires a buffer of 4 bytes [\#187](https://github.com/python-kasa/python-kasa/issues/187) -- Help request - query value [\#171](https://github.com/python-kasa/python-kasa/issues/171) -- Can't Discover Devices [\#164](https://github.com/python-kasa/python-kasa/issues/164) -- Concurrency performance question [\#110](https://github.com/python-kasa/python-kasa/issues/110) -- Define the port by self? [\#108](https://github.com/python-kasa/python-kasa/issues/108) -- Convert homeassistant integration to use the library [\#9](https://github.com/python-kasa/python-kasa/issues/9) - **Merged pull requests:** -- Prepare 0.4.1 [\#288](https://github.com/python-kasa/python-kasa/pull/288) (@rytilahti) - 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) @@ -526,43 +1256,15 @@ Pull requests improving the functionality of modules as well as adding better in ## [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.dev5...0.4.0) +[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) - -**Closed issues:** - -- Debug logging in protocol.py is the majority of the execution time [\#216](https://github.com/python-kasa/python-kasa/issues/216) - -**Merged pull requests:** - -- Release 0.4.0 [\#221](https://github.com/python-kasa/python-kasa/pull/221) (@rytilahti) -- 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) - -## [0.4.0.dev5](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev5) (2021-09-24) - -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev4...0.4.0.dev5) - -**Implemented enhancements:** - - Keep connection open and lock to prevent duplicate requests [\#213](https://github.com/python-kasa/python-kasa/pull/213) (@bdraco) - -**Merged pull requests:** - -- Release 0.4.0.dev5 [\#215](https://github.com/python-kasa/python-kasa/pull/215) (@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) - -## [0.4.0.dev4](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev4) (2021-09-23) - -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev3...0.4.0.dev4) - -**Implemented enhancements:** - - 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) @@ -571,30 +1273,38 @@ Pull requests improving the functionality of modules as well as adding better in - 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) -**Closed issues:** +**Project maintenance:** -- Feature Request - Toggle Command [\#188](https://github.com/python-kasa/python-kasa/issues/188) -- Is It Compatible With HS105? [\#186](https://github.com/python-kasa/python-kasa/issues/186) -- Cannot use some functions with KP303 [\#181](https://github.com/python-kasa/python-kasa/issues/181) -- Help needed - awaiting game [\#179](https://github.com/python-kasa/python-kasa/issues/179) -- Version inconsistency between CLI and pip [\#177](https://github.com/python-kasa/python-kasa/issues/177) -- Release 0.4.0.dev3? [\#169](https://github.com/python-kasa/python-kasa/issues/169) -- Can't command or query HS200 v5 switch [\#161](https://github.com/python-kasa/python-kasa/issues/161) +- Add HS220 hw 2.0 fixture [\#107](https://github.com/python-kasa/python-kasa/pull/107) (@appleguru) **Merged pull requests:** -- Release 0.4.0.dev4 [\#210](https://github.com/python-kasa/python-kasa/pull/210) (@rytilahti) +- 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) @@ -604,41 +1314,6 @@ Pull requests improving the functionality of modules as well as adding better in - 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) - -## [0.4.0.dev3](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev3) (2021-06-16) - -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev2...0.4.0.dev3) - -**Fixed bugs:** - -- `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) -- 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) - -**Documentation updates:** - -- Add ability to control individual sockets on KP400 [\#121](https://github.com/python-kasa/python-kasa/issues/121) -- Improve cli documentation for bulbs and power strips [\#123](https://github.com/python-kasa/python-kasa/pull/123) (@rytilahti) - -**Closed issues:** - -- After installing, command `kasa` not found [\#165](https://github.com/python-kasa/python-kasa/issues/165) -- KL430 causing "non-hexadecimal number found in fromhex\(\) arg at position 2" error in smartdevice.py [\#159](https://github.com/python-kasa/python-kasa/issues/159) -- Cant get smart strip children to work [\#144](https://github.com/python-kasa/python-kasa/issues/144) -- `kasa --host 192.168.1.67 wifi join ` does not change network [\#139](https://github.com/python-kasa/python-kasa/issues/139) -- Poetry returns error when installing dependencies [\#131](https://github.com/python-kasa/python-kasa/issues/131) -- 'kasa wifi scan' raises RuntimeError [\#127](https://github.com/python-kasa/python-kasa/issues/127) -- Runtime Error when I execute Kasa emeter command [\#124](https://github.com/python-kasa/python-kasa/issues/124) -- HS105\(US\) HW 5.0/SW 1.0.2 Not Working [\#119](https://github.com/python-kasa/python-kasa/issues/119) -- HS110\(UK\) not discoverable [\#113](https://github.com/python-kasa/python-kasa/issues/113) -- Stopping Kasa SmartDevices from phoning home [\#111](https://github.com/python-kasa/python-kasa/issues/111) -- TP Link Dimmer switch \(HS220\) hardware version 2.0 not being discovered [\#105](https://github.com/python-kasa/python-kasa/issues/105) -- Support for P100 Smart Plug [\#83](https://github.com/python-kasa/python-kasa/issues/83) - -**Merged pull requests:** - -- Prepare 0.4.0.dev3 [\#172](https://github.com/python-kasa/python-kasa/pull/172) (@rytilahti) - 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) @@ -646,66 +1321,7 @@ Pull requests improving the functionality of modules as well as adding better in - 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) -- Add HS220 hw 2.0 fixture [\#107](https://github.com/python-kasa/python-kasa/pull/107) (@appleguru) - -## [0.4.0.dev2](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev2) (2020-11-21) - -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev1...0.4.0.dev2) - -**Implemented enhancements:** - -- 'Interface' parameter added to discovery process [\#79](https://github.com/python-kasa/python-kasa/pull/79) (@dmitryelj) - -**Fixed bugs:** - -- 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) - -**Closed issues:** - -- TPLINK HS100 firmware 4.1 no longer has TCP 9999 available [\#114](https://github.com/python-kasa/python-kasa/issues/114) -- 7.1.2 Update to asyncclick breaks github install of python-kasa [\#106](https://github.com/python-kasa/python-kasa/issues/106) -- cli emeter year and month functions fail [\#102](https://github.com/python-kasa/python-kasa/issues/102) -- how to know the duration for which the plug was ON? [\#99](https://github.com/python-kasa/python-kasa/issues/99) -- problem controlling the smartplug through a controller [\#98](https://github.com/python-kasa/python-kasa/issues/98) -- unable to install [\#97](https://github.com/python-kasa/python-kasa/issues/97) -- Install on Ubuntu 18.04 no luck [\#96](https://github.com/python-kasa/python-kasa/issues/96) -- issue with installation [\#95](https://github.com/python-kasa/python-kasa/issues/95) -- Running via Crontab [\#92](https://github.com/python-kasa/python-kasa/issues/92) -- Issues with setup [\#91](https://github.com/python-kasa/python-kasa/issues/91) - -**Merged pull requests:** - -- Release 0.4.0.dev2 [\#118](https://github.com/python-kasa/python-kasa/pull/118) (@rytilahti) - Pin dependencies on major versions, add poetry.lock [\#94](https://github.com/python-kasa/python-kasa/pull/94) (@rytilahti) - -## [0.4.0.dev1](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev1) (2020-07-28) - -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.dev0...0.4.0.dev1) - -**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) - -**Documentation updates:** - -- Improve poetry usage documentation [\#60](https://github.com/python-kasa/python-kasa/issues/60) - -**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) -- 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) -- 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) - -**Merged pull requests:** - -- Release 0.4.0.dev1 [\#93](https://github.com/python-kasa/python-kasa/pull/93) (@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) @@ -717,10 +1333,6 @@ Pull requests improving the functionality of modules as well as adding better in - 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.dev0](https://github.com/python-kasa/python-kasa/tree/0.4.0.dev0) (2020-05-27) - -[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.4.0.pre0...0.4.0.dev0) - ## [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) @@ -733,24 +1345,6 @@ Pull requests improving the functionality of modules as well as adding better in - HSV cli command not working [\#43](https://github.com/python-kasa/python-kasa/issues/43) -**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) - **Merged pull requests:** - Add retries to protocol queries [\#65](https://github.com/python-kasa/python-kasa/pull/65) (@rytilahti) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..b6d851f9 --- /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 fdc9a4b8..c286ba3f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

python-kasa

+# python-kasa [![PyPI version](https://badge.fury.io/py/python-kasa.svg)](https://badge.fury.io/py/python-kasa) [![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) @@ -20,18 +20,12 @@ You can install the most recent release using pip: pip install python-kasa ``` -If you are using cpython, it is recommended to install with `[speedups]` to enable orjson (faster json support): -``` -pip install python-kasa[speedups] -``` - -With `[speedups]`, the protocol overhead is roughly an order of magnitude lower (benchmarks available in devtools). - -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). @@ -39,7 +33,11 @@ If you have not yet provisioned your device, [you can do so using the cli tool]( ## Discovering devices Running `kasa discover` will send discovery packets to the default broadcast address (`255.255.255.255`) to discover supported devices. -If your system has multiple network interfaces, you can specify the broadcast address using the `--target` option. +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: @@ -47,259 +45,178 @@ The `discover` command will automatically execute the `state` command on all the $ kasa discover Discovering devices on 255.255.255.255 for 3 seconds -== Bulb McBulby - KL130(EU) == - Host: 192.168.xx.xx - Port: 9999 - Device state: True - == Generic information == - Time: 2023-12-05 14:33:23 (tz: {'index': 6, 'err_code': 0} - Hardware: 1.0 - Software: 1.8.8 Build 190613 Rel.123436 - MAC (rssi): 1c:3b:f3:xx:xx:xx (-56) - Location: {'latitude': None, 'longitude': None} +== 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 - == Device specific information == - Brightness: 16 - Is dimmable: True - Color temperature: 2500 - Valid temperature range: ColorTempRange(min=2500, max=9000) - HSV: HSV(hue=0, saturation=0, value=16) - Presets: - index=0 brightness=50 hue=0 saturation=0 color_temp=2500 custom=None id=None mode=None - index=1 brightness=100 hue=299 saturation=95 color_temp=0 custom=None id=None mode=None - index=2 brightness=100 hue=120 saturation=75 color_temp=0 custom=None id=None mode=None - index=3 brightness=100 hue=240 saturation=75 color_temp=0 custom=None id=None mode=None +== 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 - == Current State == - +== 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) - == Modules == - + - + - + - + - + - - - + +== 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 ``` -If your device requires authentication to control it, -you need to pass the credentials using `--username` and `--password` options. -## Basic functionalities +## Command line usage -All devices support a variety of common commands, including: +All devices support a variety of common commands (like `on`, `off`, and `state`). +The syntax to control device is `kasa --host `: -* `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 +``` +$ kasa --host 192.0.2.123 on +``` -The syntax to control device is `kasa --host `. 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`), defining timeouts (`--timeout` and `--discovery-timeout`). +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. -Each individual command may also have additional options, which are shown when called with the `--help` option. -For example, `--transition` on bulbs requests a smooth state change, while `--name` and `--index` are used on power strips to select the socket to act on: +> [!NOTE] +> Each individual command may also have additional options, which are shown when called with the `--help` option. + + +### Feature interface + +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 on --help +$ 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 -Usage: kasa on [OPTIONS] +== 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 - Turn the device on. +== 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) -Options: - --index INTEGER - --name TEXT - --transition INTEGER - --help Show this message and exit. +== 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 ``` - -### Bulbs - -Common commands for bulbs and light strips include: - -* `brightness` to control the brightness -* `hsv` to control the colors -* `temperature` to control the color temperatures - -When executed without parameters, these commands will report the current state. - -Some devices support `--transition` option to perform a smooth state change. -For example, the following turns the light to 30% brightness over a period of five seconds: +Some features present configuration that can be changed: ``` -$ kasa --host brightness --transition 5000 30 +kasa --host 192.0.2.123 feature color_temperature 2500 +Changing color_temperature from 0 to 2500 +New state: 2500 ``` -See `--help` for additional options and [the documentation](https://python-kasa.readthedocs.io/en/latest/smartbulb.html) for more details about supported features and limitations. - -### Power strips - -Each individual socket can be controlled separately by passing `--index` or `--name` to the command. -If neither option is defined, the commands act on the whole power strip. - -For example: -``` -$ kasa --host off # turns off all sockets -$ kasa --host off --name 'Socket1' # turns off socket named 'Socket1' -``` - -See `--help` for additional options and [the documentation](https://python-kasa.readthedocs.io/en/latest/smartstrip.html) for more details about supported features and limitations. +> [!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` -## Energy meter - -Running `kasa emeter` command will return the current consumption. -Possible options include `--year` and `--month` for retrieving historical state, -and reseting the counters can be done with `--erase`. +## Library usage ``` -$ kasa emeter -== Emeter == -Current state: {'total': 133.105, 'power': 108.223577, 'current': 0.54463, 'voltage': 225.296283} +import asyncio +from kasa import Discover + +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() + +if __name__ == "__main__": + asyncio.run(main()) ``` -# Library usage +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). -If you want to use this library in your own project, a good starting point is to check [the documentation on discovering devices](https://python-kasa.readthedocs.io/en/latest/discover.html). -You can find several code examples in the API documentation of each of the implementation base classes, check out the [documentation for the base class shared by all supported devices](https://python-kasa.readthedocs.io/en/latest/smartdevice.html). +You can find several code examples in the API documentation [How to guides](https://python-kasa.readthedocs.io/en/latest/guides.html). -[The library design and module structure is described in a separate page](https://python-kasa.readthedocs.io/en/latest/design.html). - -The device type specific documentation can be found in their separate pages: -* [Plugs](https://python-kasa.readthedocs.io/en/latest/smartplug.html) -* [Bulbs](https://python-kasa.readthedocs.io/en/latest/smartbulb.html) -* [Dimmers](https://python-kasa.readthedocs.io/en/latest/smartdimmer.html) -* [Power strips](https://python-kasa.readthedocs.io/en/latest/smartstrip.html) -* [Light strips](https://python-kasa.readthedocs.io/en/latest/smartlightstrip.html) +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). ## Contributing -Contributions are very welcome! To simplify the process, we are leveraging automated checks and tests for contributions. - -### Setting up development environment - -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. - -### Code-style checks - -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. - -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. - -### Running tests - -You can run tests on the library by executing `pytest` in the source directory. -This will run the tests against contributed example responses, but you can also execute the tests against a real device: -``` -$ pytest --ip
-``` -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#wireshark-dissector) -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. - +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). ## Supported devices -In principle, most kasa-branded devices that are locally controllable using the official Kasa mobile app work with this library. +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). -The following lists the devices that have been manually verified to work. -**If your device is unlisted but working, please open a pull request to update the list and add a fixture file (use `devtools/dump_devinfo.py` to generate one).** +> [!NOTE] +> The hub attached Tapo buttons S200B and S200D do not currently support alerting when the button is pressed. -### Plugs +> [!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. -* HS100 -* HS103 -* HS105 -* HS107 -* HS110 -* KP100 -* KP105 -* KP115 -* KP125 -* KP125M [See note below](#tapo-and-newer-kasa-branded-devices) -* KP401 -* EP10 -* EP25 [See note below](#tapo-and-newer-kasa-branded-devices) + + +### Supported Kasa devices -### Power Strips +- **Plugs**: EP10, EP25[^1], 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, KL120, KL125, KL130, KL135, KL50, KL60, LB110 +- **Light Strips**: KL400L5, KL420L5, KL430 +- **Hubs**: KH100[^1] +- **Hub-Connected Devices[^3]**: KE100[^1] -* EP40 -* HS300 -* KP303 -* KP200 (in wall) -* KP400 -* KP405 (dimmer) +### Supported Tapo[^1] devices -### Wall switches +- **Plugs**: P100, P110, P110M, P115, P125M, P135, TP15 +- **Power Strips**: P300, P304M, TP25 +- **Wall Switches**: S500D, S505, S505D +- **Bulbs**: L510B, L510E, L530E, L630 +- **Light Strips**: L900-10, L900-5, L920-5, L930-5 +- **Cameras**: C100, C210, C325WB, C520WS, TC65, TC70 +- **Hubs**: H100, H200 +- **Hub-Connected Devices[^3]**: S200B, S200D, T100, T110, T300, T310, T315 -* ES20M -* HS200 -* HS210 -* HS220 -* KS200M (partial support, no motion, no daylight detection) -* KS220M (partial support, no motion, no daylight detection) -* KS230 + +[^1]: Model requires authentication +[^2]: Newer versions require authentication +[^3]: Devices may work across TAPO/KASA branded hubs -### Bulbs - -* LB100 -* LB110 -* LB120 -* LB130 -* LB230 -* KL50 -* KL60 -* KL110 -* KL120 -* KL125 -* KL130 -* KL135 - -### Light strips - -* KL400L5 -* KL420L5 -* KL430 - -### Tapo and newer Kasa branded devices - -The library has recently added a limited supported for devices that carry Tapo branding. - -At the moment, the following devices have been confirmed to work: - -* Tapo P110 (plug) -* Tapo L530E (bulb) -* Tapo L900-5 (led strip) -* Tapo L900-10 (led strip) -* Kasa KS205 (Wifi/Matter Wall Switch) -* Kasa KS225 (Wifi/Matter Wall Dimmer Switch) - -Some newer hardware versions of Kasa branded devices are now using the same protocol as -Tapo branded devices. Support for these devices is currently limited as per TAPO branded -devices: - -* Kasa EP25 (plug) hw_version 2.6 -* Kasa KP125M (plug) - -**If your device is unlisted but working, please open a pull request to update the list and add a fixture file (use `devtools/dump_devinfo.py` to generate one).** +See [supported devices in our documentation](SUPPORTED.md) for more detailed information about tested hardware and software versions. ## Resources ### Developer Resources -* [softScheck's github contains lot of information and wireshark dissector](https://github.com/softScheck/tplink-smartplug#wireshark-dissector) +* [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) @@ -310,17 +227,14 @@ devices: * [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) -### TP-Link Tapo support - -This library has recently added a limited supported for devices that carry Tapo branding. -That support is currently limited to the cli. The package `kasa.tapo` is in flux and if you -use it directly you should expect it could break in future releases until this statement is removed. - -Other TAPO libraries are: +### Other related projects * [PyTapo - Python library for communication with Tapo Cameras](https://github.com/JurajNyiri/pytapo) -* [Tapo P100 (Tapo P105/P100 plugs, Tapo L510E bulbs)](https://github.com/fishbigger/TapoP100) + * [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 37d2e4ca..e3527cea 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,55 +1,313 @@ -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.dev4 -``` - -2. Update the version number - -```bash -poetry version $NEW_RELEASE -``` - -3. Write a short and understandable summary for the release. - -* Create a new issue and label it with release-summary -* Create $NEW_RELEASE milestone in github, and assign the issue to that -* Close the issue - -3. Generate changelog - -```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 ``` -4. Commit the changed files +## 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 -git commit -av +export NEW_RELEASE=x.x.x.devx ``` -5. Create a PR for the release. +## Normal releases from master -6. Get it merged, fetch the upstream master +### Create a branch for the release ```bash git checkout master -git fetch upstream +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 +``` + +### Update dependencies + +```bash +uv sync --all-extras +uv lock --upgrade +uv sync --all-extras +``` + +### Run pre-commit and tests + +```bash +uv run pre-commit run --all-files +uv run pytest -n auto +``` + +### 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 + +#### 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 +``` + +#### 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 master git rebase upstream/master ``` -7. 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 -a $NEW_RELEASE +git tag --annotate $NEW_RELEASE -m "$RELEASE_NOTES" git push upstream $NEW_RELEASE ``` -All tags on master branch will trigger a new release on pypi. +### Create release -8. Click the "Draft a new release" button on github, select the new tag and copy & paste the changelog into the description. +#### 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 +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 +``` + +### Create release + +```bash +gh release create "$NEW_RELEASE" --verify-tag --notes-from-tag --title "$NEW_RELEASE" --draft --latest=true +``` +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 +``` + +#### 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 +gh pr merge --merge --body "" +``` +#### Revert allow merge commits + +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 00000000..5d26f8e9 --- /dev/null +++ b/SUPPORTED.md @@ -0,0 +1,310 @@ +# 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: 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 +- **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] +- **KS230** + - Hardware: 1.0 (US) / Firmware: 1.0.14 +- **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 +- **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 +- **LB110** + - Hardware: 1.0 (US) / Firmware: 1.8.11 + +### Light Strips + +- **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 +- **P110** + - 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 +- **TP15** + - Hardware: 1.0 (US) / Firmware: 1.0.3 + +### Power Strips + +- **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 +- **TP25** + - Hardware: 1.0 (US) / Firmware: 1.0.2 + +### Wall Switches + +- **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 + +### Bulbs + +- **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 +- **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 (US) / Firmware: 1.1.0 +- **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 (US) / Firmware: 1.1.2 + +### Cameras + +- **C100** + - Hardware: 4.0 / Firmware: 1.3.14 +- **C210** + - Hardware: 2.0 (EU) / Firmware: 1.4.2 + - Hardware: 2.0 (EU) / Firmware: 1.4.3 +- **C325WB** + - Hardware: 1.0 (EU) / Firmware: 1.1.17 +- **C520WS** + - Hardware: 1.0 (US) / Firmware: 1.2.8 +- **TC65** + - Hardware: 1.0 / Firmware: 1.3.9 +- **TC70** + - Hardware: 3.0 / Firmware: 1.3.11 + +### Hubs + +- **H100** + - 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 (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 +- **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/devtools/README.md b/devtools/README.md index 50425c25..f59ea374 100644 --- a/devtools/README.md +++ b/devtools/README.md @@ -3,18 +3,24 @@ This directory contains some simple scripts that can be useful for developers. ## dump_devinfo -* Queries the device and returns a fixture that can be added to the test suite +* Queries the device (if --host is given) or discover devices and creates fixture files that can be added to the test suite. ```shell -Usage: dump_devinfo.py [OPTIONS] HOST +Usage: python -m devtools.dump_devinfo [OPTIONS] - Generate devinfo file for given device. + 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. - --username For authenticating devices. - --password + --help Show this message and exit. ``` ## create_module_fixtures @@ -93,3 +99,30 @@ id 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/bench/benchmark.py b/devtools/bench/benchmark.py index 2cdbd43e..91a3a93d 100644 --- a/devtools/bench/benchmark.py +++ b/devtools/bench/benchmark.py @@ -5,8 +5,9 @@ import timeit import orjson from kasa_crypt import decrypt, encrypt -from utils.data import REQUEST, WIRE_RESPONSE -from utils.original import OriginalTPLinkSmartHomeProtocol + +from devtools.bench.utils.data import REQUEST, WIRE_RESPONSE +from devtools.bench.utils.original import OriginalTPLinkSmartHomeProtocol def original_request_response() -> None: diff --git a/devtools/bench/utils/data.py b/devtools/bench/utils/data.py index 13a49e87..27adc0ea 100644 --- a/devtools/bench/utils/data.py +++ b/devtools/bench/utils/data.py @@ -1,6 +1,5 @@ """Test data for benchmarks.""" - import json from .original import OriginalTPLinkSmartHomeProtocol diff --git a/devtools/bench/utils/original.py b/devtools/bench/utils/original.py index 67aeaa33..27e9088a 100644 --- a/devtools/bench/utils/original.py +++ b/devtools/bench/utils/original.py @@ -1,6 +1,7 @@ """Original implementation of the TP-Link Smart Home protocol.""" + import struct -from typing import Generator +from collections.abc import Generator class OriginalTPLinkSmartHomeProtocol: diff --git a/devtools/check_readme_vs_fixtures.py b/devtools/check_readme_vs_fixtures.py deleted file mode 100644 index 1f55eea8..00000000 --- a/devtools/check_readme_vs_fixtures.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Script that checks if README.md is missing devices that have fixtures.""" -from kasa.tests.conftest import ( - ALL_DEVICES, - BULBS, - DIMMERS, - LIGHT_STRIPS, - PLUGS, - STRIPS, -) - -with open("README.md") as f: - readme = f.read() - -typemap = { - "light strips": LIGHT_STRIPS, - "bulbs": BULBS, - "plugs": PLUGS, - "strips": STRIPS, - "dimmers": DIMMERS, -} - - -def _get_device_type(dev, typemap): - for typename, devs in typemap.items(): - if dev in devs: - return typename - else: - return "Unknown type" - - -for dev in ALL_DEVICES: - if dev not in readme: - print(f"{dev} not listed in {_get_device_type(dev, typemap)}") diff --git a/devtools/create_module_fixtures.py b/devtools/create_module_fixtures.py index 1e0f17f7..ed881a88 100644 --- a/devtools/create_module_fixtures.py +++ b/devtools/create_module_fixtures.py @@ -6,18 +6,20 @@ 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, SmartDevice +from kasa import Discover +from kasa.iot import IotDevice app = typer.Typer() -def create_fixtures(dev: SmartDevice, outputdir: Path): +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 / name + module_dir = outputdir / str(name) if not module_dir.exists(): module_dir.mkdir(exist_ok=True, parents=True) @@ -43,13 +45,14 @@ def create_module_fixtures( """Create module fixtures for given host/network.""" devs = [] if host is not None: - dev: SmartDevice = asyncio.run(Discover.discover_single(host)) + 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: diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 1fb147c4..e985ab40 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -1,88 +1,126 @@ """This script generates devinfo files for the test suite. If you have new, yet unsupported device or a device with no devinfo file under - kasa/tests/fixtures, feel free to run this script and create a PR to add the file + 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. """ -import base64 -import collections.abc + +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 Dict, List +from typing import Any import asyncclick as click -from devtools.helpers.smartrequests import COMPONENT_REQUESTS, SmartRequest -from kasa import AuthenticationException, Credentials, Discover, SmartDevice -from kasa.discover import DiscoveryResult +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.tapo.tapodevice import TapoDevice +from kasa.protocols import IotProtocol +from kasa.protocols.iotprotocol import REDACTORS as IOT_REDACTORS +from kasa.protocols.protocol import redact_data +from kasa.protocols.smartcamprotocol import ( + SmartCamProtocol, + _ChildCameraProtocolWrapper, +) +from kasa.protocols.smartprotocol import REDACTORS as SMART_REDACTORS +from kasa.protocols.smartprotocol import SmartProtocol, _ChildProtocolWrapper +from kasa.smart import SmartChildDevice, SmartDevice +from kasa.smartcam import SmartCamDevice Call = namedtuple("Call", "module method") -SmartCall = namedtuple("SmartCall", "module request should_succeed") +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/" +IOT_FOLDER = "tests/fixtures/iot/" + +SMART_PROTOCOL_SUFFIX = "SMART" +SMARTCAM_SUFFIX = "SMARTCAM" +SMART_CHILD_SUFFIX = "SMART.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 scrub(res): - """Remove identifiers from the given dict.""" - keys_to_scrub = [ - "deviceId", - "fwId", - "hwId", - "oemId", - "mac", - "mic_mac", - "latitude_i", - "longitude_i", - "latitude", - "longitude", - "owner", - "device_id", - "ip", - "ssid", - "hw_id", - "fw_id", - "oem_id", - "nickname", - "alias", - "bssid", - "channel", - ] +def _wrap_redactors(redactors: dict[str, Callable[[Any], Any] | None]): + """Wrap the redactors for dump_devinfo. - for k, v in res.items(): - if isinstance(v, collections.abc.Mapping): - res[k] = scrub(res.get(k)) - elif ( - isinstance(v, list) - and len(v) > 0 - and isinstance(v[0], collections.abc.Mapping) - ): - res[k] = [scrub(vi) for vi in v] - else: - if k in keys_to_scrub: - if k in ["latitude", "latitude_i", "longitude", "longitude_i"]: - v = 0 - elif k in ["ip"]: - v = "127.0.0.123" - elif k in ["ssid"]: - # Need a valid base64 value here - v = base64.b64encode(b"#MASKED_SSID#").decode() - elif k in ["nickname"]: - v = base64.b64encode(b"#MASKED_NAME#").decode() - elif k in ["alias"]: - v = "#MASKED_NAME#" - elif isinstance(res[k], int): - v = 0 - else: - v = re.sub(r"\w", "0", v) + Will replace all partial REDACT_ values with zeros. + If the data item is already scrubbed by dump_devinfo will leave as-is. + """ - res[k] = v - return res + 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): @@ -95,8 +133,57 @@ def default_to_regular(d): 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.argument("host") +@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="", @@ -111,51 +198,226 @@ def default_to_regular(d): 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) -async def cli(host, debug, username, password): - """Generate devinfo file for given device.""" +@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) - device = await Discover.discover_single(host, credentials=credentials) + if host is not None: + if discovery_info: + click.echo(f"Host and discovery info given, trying connect on {host}.") - if isinstance(device, TapoDevice): - save_filename, copy_folder, final = await get_smart_fixture(device) + di = json.loads(discovery_info) + dr = DiscoveryResult.from_dict(di) + connection_type = DeviceConnectionParameters.from_values( + dr.device_type, + dr.mgt_encrypt_schm.encrypt_type, + dr.mgt_encrypt_schm.lv, + ) + 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: - save_filename, copy_folder, final = await get_legacy_fixture(device) - - pprint(scrub(final)) - 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 open(save_filename, "w") as f: - json.dump(final, f, sort_keys=True, indent=4) - f.write("\n") - click.echo( - f"Saved. Copy/Move {save_filename} to " - + f"{copy_folder} to add it to the test suite" + "No --host given, performing discovery on" + f" {target}. Use --target to override." ) - else: - click.echo("Not saving.") + 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(device): +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"), ] successes = [] @@ -163,9 +425,7 @@ async def get_legacy_fixture(device): for test_call in items: try: click.echo(f"Testing {test_call}..", nl=False) - info = await device.protocol.query( - {test_call.module: {test_call.method: None}} - ) + 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")) @@ -175,33 +435,40 @@ async def get_legacy_fixture(device): else: click.echo(click.style("OK", fg="green")) successes.append((test_call, info)) + finally: + await protocol.close() - final_query = defaultdict(defaultdict) - final = defaultdict(defaultdict) + final_query: dict = defaultdict(defaultdict) + final: dict = defaultdict(defaultdict) for succ, resp in successes: - final_query[succ.module][succ.method] = None + final_query[succ.module][succ.method] = {} final[succ.module][succ.method] = resp final = default_to_regular(final) try: - final = await device.protocol.query(final_query) + final = await protocol.query(final_query) except Exception as ex: - click.echo( - click.style( - f"Unable to query all successes at once: {ex}", bold=True, fg="red" - ) + _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) ) - if device._discovery_info and not device._discovery_info.get("system"): - # Need to recreate a DiscoverResult here because we don't want the aliases - # in the fixture, we want the actual field names as returned by the device. - dr = DiscoveryResult(**device._discovery_info) - final["discovery_result"] = dr.dict( - by_alias=False, exclude_unset=True, exclude_none=True, exclude_defaults=True - ) - - click.echo("Got %s successes" % len(successes)) + click.echo(f"Got {len(successes)} successes") click.echo(click.style("## device info file ##", bold=True)) sysinfo = final["system"]["get_sysinfo"] @@ -209,125 +476,431 @@ async def get_legacy_fixture(device): hw_version = sysinfo["hw_ver"] sw_version = sysinfo["sw_ver"] sw_version = sw_version.split(" ", maxsplit=1)[0] - save_filename = f"{model}_{hw_version}_{sw_version}.json" - copy_folder = "kasa/tests/fixtures/" - return save_filename, copy_folder, final + 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( - device: SmartDevice, requests: List[SmartRequest], name: str -) -> Dict[str, Dict]: + 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 = 5 # Break the requests down as there seems to be a size limit + 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 = requests[x : x + step] - responses = await device.protocol.query( - SmartRequest._create_request_dict(requests_step) - ) + 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 AuthenticationException as ex: - click.echo( - click.style( - f"Unable to query the device due to an authentication error: {ex}", - bold=True, - fg="red", - ) + 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: - click.echo( - click.style(f"Unable to query {name} at once: {ex}", bold=True, fg="red") + _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_fixture(device: TapoDevice): - """Get fixture for new TAPO style protocol.""" +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 = {} + extra_test_calls = [ SmartCall( module="temp_humidity_records", - request=SmartRequest.get_raw_request("get_temp_humidity_records"), - should_succeed=False, - ), - SmartCall( - module="child_device_list", - request=SmartRequest.get_raw_request("get_child_device_list"), - should_succeed=False, - ), - SmartCall( - module="child_device_component_list", - request=SmartRequest.get_raw_request("get_child_device_component_list"), - should_succeed=False, - ), - SmartCall( - module="trigger_logs", - request=SmartRequest.get_raw_request( - "get_trigger_logs", SmartRequest.GetTriggerLogsParams(5, 0) - ), + request=SmartRequest.get_raw_request("get_temp_humidity_records").to_dict(), should_succeed=False, + child_device_id="", ), ] - successes = [] - click.echo("Testing component_nego call ..", nl=False) responses = await _make_requests_or_exit( - device, [SmartRequest.component_nego()], "component_nego call" + 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"), + 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"] + } - test_calls = [] - should_succeed = [] + 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"] + } - for item in component_info_response["component_list"]: - component_id = item["id"] - if requests := COMPONENT_REQUESTS.get(component_id): + # 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=request, should_succeed=True) - for request in requests + 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) - should_succeed.extend(component_test_calls) - elif component_id not in COMPONENT_REQUESTS: + else: click.echo(f"Skipping {component_id}..", nl=False) click.echo(click.style("UNSUPPORTED", fg="yellow")) test_calls.extend(extra_test_calls) + # Child component calls + for child_device_id, child_components in child_device_components.items(): + test_calls.append( + 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")) + # Add the extra calls for each child + for extra_call in extra_test_calls: + extra_child_call = dataclasses.replace( + extra_call, child_device_id=child_device_id + ) + test_calls.append(extra_child_call) + + return test_calls, successes + + +def get_smart_child_fixture(response): + """Get a seperate fixture for the child device.""" + model_info = SmartDevice._get_device_info(response, None) + hw_version = model_info.hardware_version + fw_version = model_info.firmware_version + model = model_info.long_name + if model_info.region is not None: + model = f"{model}({model_info.region})" + save_filename = f"{model}_{hw_version}_{fw_version}" + return FixtureResult( + filename=save_filename, + folder=SMART_CHILD_FOLDER, + data=response, + protocol_suffix=SMART_CHILD_SUFFIX, + ) + + +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) - response = await device.protocol.query( - SmartRequest._create_request_dict(test_call.request) - ) - except AuthenticationException as ex: - click.echo( - click.style( - f"Unable to query the device due to an authentication error: {ex}", - bold=True, - fg="red", - ) + 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 == SmartErrorCode.UNKNOWN_METHOD_ERROR + 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: @@ -341,31 +914,121 @@ async def get_smart_fixture(device: TapoDevice): else: click.echo(click.style("OK", fg="green")) successes.append(test_call) + finally: + await protocol.close() - requests = [] - for succ in successes: - requests.append(succ.request) + 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_requests_or_exit(device, requests, "all successes at once") + scrubbed_device_ids = { + device_id: f"SCRUBBED_CHILD_DEVICE_ID_{index}" + for index, device_id in enumerate(device_requests.keys()) + if device_id != "" + } - # Need to recreate a DiscoverResult here because we don't want the aliases - # in the fixture, we want the actual field names as returned by the device. - dr = DiscoveryResult(**device._discovery_info) # type: ignore - final["discovery_result"] = dr.dict( - by_alias=False, exclude_unset=True, exclude_none=True, exclude_defaults=True + final = await _make_final_calls( + protocol, device_requests[""], "All successes", batch_size, child_device_id="" ) + fixture_results = [] + 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, + ) - click.echo("Got %s successes" % len(successes)) + scrubbed = scrubbed_device_ids[child_device_id] + if "get_device_info" in response and "device_id" in response["get_device_info"]: + response["get_device_info"]["device_id"] = scrubbed + # If the child is a different model to the parent create a seperate fixture + if "get_device_info" in final: + parent_model = final["get_device_info"]["model"] + elif "getDeviceInfo" in final: + parent_model = final["getDeviceInfo"]["device_info"]["basic_info"][ + "device_model" + ] + else: + raise KasaException("Cannot determine parent device model.") + if ( + "component_nego" in response + and "get_device_info" in response + and (child_model := response["get_device_info"].get("model")) + and child_model != parent_model + ): + response = redact_data(response, _wrap_redactors(SMART_REDACTORS)) + fixture_results.append(get_smart_child_fixture(response)) + else: + cd = final.setdefault("child_devices", {}) + cd[scrubbed] = response + + # Scrub the device ids in the parent for smart protocol + if gc := final.get("get_child_device_component_list"): + for child in gc["child_component_list"]: + device_id = child["device_id"] + child["device_id"] = scrubbed_device_ids[device_id] + for child in final["get_child_device_list"]["child_device_list"]: + device_id = child["device_id"] + child["device_id"] = scrubbed_device_ids[device_id] + + # Scrub the device ids in the parent for the smart camera protocol + if gc := final.get("getChildDeviceComponentList"): + for child in gc["child_component_list"]: + device_id = child["device_id"] + child["device_id"] = scrubbed_device_ids[device_id] + for child in final["getChildDeviceList"]["child_device_list"]: + if device_id := child.get("device_id"): + child["device_id"] = scrubbed_device_ids[device_id] + continue + elif dev_id := child.get("dev_id"): + child["dev_id"] = scrubbed_device_ids[dev_id] + continue + _LOGGER.error("Could not find a device for the child device: %s", child) + + final = redact_data(final, _wrap_redactors(SMART_REDACTORS)) + 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)) - hw_version = final["get_device_info"]["hw_ver"] - sw_version = final["get_device_info"]["fw_ver"] - model = final["discovery_result"]["device_model"] - sw_version = sw_version.split(" ", maxsplit=1)[0] + 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}.json" - copy_folder = "kasa/tests/fixtures/smart/" - return save_filename, copy_folder, final + 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__": diff --git a/devtools/generate_supported.py b/devtools/generate_supported.py new file mode 100755 index 00000000..7b4e9787 --- /dev/null +++ b/devtools/generate_supported.py @@ -0,0 +1,231 @@ +#!/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 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.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/" + + +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) + + 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, + 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/smartcamrequests.py b/devtools/helpers/smartcamrequests.py new file mode 100644 index 00000000..5759a44b --- /dev/null +++ b/devtools/helpers/smartcamrequests.py @@ -0,0 +1,66 @@ +"""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"]}}}, + {"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 index 6d7a366a..6ab53937 100644 --- a/devtools/helpers/smartrequests.py +++ b/devtools/helpers/smartrequests.py @@ -25,18 +25,18 @@ heart_beat """ +from __future__ import annotations + import logging from dataclasses import asdict, dataclass -from typing import List, Optional, Union _LOGGER = logging.getLogger(__name__) -logging.getLogger("httpx").propagate = False class SmartRequest: """Class to represent a smart protocol request.""" - def __init__(self, method_name: str, params: Optional["SmartRequestParams"] = None): + def __init__(self, method_name: str, params: SmartRequestParams | None = None): self.method_name = method_name if params: self.params = params.to_dict() @@ -76,6 +76,13 @@ class SmartRequest: 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.""" @@ -87,7 +94,7 @@ class SmartRequest: class LedStatusParams(SmartRequestParams): """LED Status params.""" - led_rule: Optional[str] = None + led_rule: str | None = None @staticmethod def from_bool(state: bool): @@ -99,91 +106,107 @@ class SmartRequest: class LightInfoParams(SmartRequestParams): """LightInfo params.""" - brightness: Optional[int] = None - color_temp: Optional[int] = None - hue: Optional[int] = None - saturation: Optional[int] = None + 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: Optional[str] = None + id: str | None = None @staticmethod def get_raw_request( - method: str, params: Optional[SmartRequestParams] = None - ) -> "SmartRequest": + method: str, params: SmartRequestParams | None = None + ) -> SmartRequest: """Send a raw request to the device.""" return SmartRequest(method, params) @staticmethod - def component_nego() -> "SmartRequest": + def component_nego() -> SmartRequest: """Get quick setup component info.""" return SmartRequest("component_nego") @staticmethod - def get_device_info() -> "SmartRequest": + def get_device_info() -> SmartRequest: """Get device info.""" return SmartRequest("get_device_info") @staticmethod - def get_device_usage() -> "SmartRequest": + def get_device_usage() -> SmartRequest: """Get device usage.""" return SmartRequest("get_device_usage") @staticmethod - def device_info_list() -> List["SmartRequest"]: + 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": + def get_auto_update_info() -> SmartRequest: """Get auto update info.""" return SmartRequest("get_auto_update_info") @staticmethod - def firmware_info_list() -> List["SmartRequest"]: + def firmware_info_list() -> list[SmartRequest]: """Get info list.""" return [ - SmartRequest.get_auto_update_info(), SmartRequest.get_raw_request("get_fw_download_state"), SmartRequest.get_raw_request("get_latest_fw"), ] @staticmethod - def qs_component_nego() -> "SmartRequest": + def qs_component_nego() -> SmartRequest: """Get quick setup component info.""" return SmartRequest("qs_component_nego") @staticmethod - def get_device_time() -> "SmartRequest": + def get_device_time() -> SmartRequest: """Get device time.""" return SmartRequest("get_device_time") @staticmethod - def get_wireless_scan_info() -> "SmartRequest": - """Get wireless scan info.""" - return SmartRequest("get_wireless_scan_info") + def get_child_device_list() -> SmartRequest: + """Get child device list.""" + return SmartRequest("get_child_device_list") @staticmethod - def get_schedule_rules(params: Optional[GetRulesParams] = None) -> "SmartRequest": - """Get schedule rules.""" + 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_schedule_rules", params or SmartRequest.GetRulesParams() + "get_wireless_scan_info", params or SmartRequest.GetRulesParams() ) @staticmethod - def get_next_event(params: Optional[GetRulesParams] = None) -> "SmartRequest": + 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"]: + def schedule_info_list() -> list[SmartRequest]: """Get schedule info list.""" return [ SmartRequest.get_schedule_rules(), @@ -191,38 +214,38 @@ class SmartRequest: ] @staticmethod - def get_countdown_rules(params: Optional[GetRulesParams] = None) -> "SmartRequest": + 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: Optional[GetRulesParams] = None) -> "SmartRequest": + 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: Optional[LedStatusParams] = None) -> "SmartRequest": + 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: Optional[GetRulesParams] = None) -> "SmartRequest": + 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": + def get_delay_action_info() -> SmartRequest: """Get delay action info.""" return SmartRequest("get_delay_action_info") @staticmethod - def auto_off_list() -> List["SmartRequest"]: + def auto_off_list() -> list[SmartRequest]: """Get energy usage.""" return [ SmartRequest.get_auto_off_config(), @@ -230,25 +253,27 @@ class SmartRequest: ] @staticmethod - def get_energy_usage() -> "SmartRequest": + def get_energy_usage() -> SmartRequest: """Get energy usage.""" return SmartRequest("get_energy_usage") @staticmethod - def energy_monitoring_list() -> List["SmartRequest"]: + 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": + def get_current_power() -> SmartRequest: """Get current power.""" return SmartRequest("get_current_power") @staticmethod - def power_protection_list() -> List["SmartRequest"]: + def power_protection_list() -> list[SmartRequest]: """Get power protection info list.""" return [ SmartRequest.get_current_power(), @@ -257,53 +282,66 @@ class SmartRequest: ] @staticmethod - def get_preset_rules(params: Optional[GetRulesParams] = None) -> "SmartRequest": + def get_preset_rules(params: GetRulesParams | None = None) -> SmartRequest: """Get preset rules.""" return SmartRequest("get_preset_rules", params or SmartRequest.GetRulesParams()) @staticmethod - def get_auto_light_info() -> "SmartRequest": + 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: Optional[GetRulesParams] = None - ) -> "SmartRequest": + 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": + 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": + 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": + 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"]: + def get_component_info_requests(component_nego_response) -> list[SmartRequest]: """Get a list of requests based on the component info response.""" - request_list = [] + request_list: list[SmartRequest] = [] for component in component_nego_response["component_list"]: - if requests := COMPONENT_REQUESTS.get(component["id"]): + 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: Union["SmartRequest", List["SmartRequest"]] + smart_request: SmartRequest | list[SmartRequest], ) -> dict: """Create request dict to be passed to SmartProtocol.query().""" if isinstance(smart_request, list): @@ -315,8 +353,17 @@ class SmartRequest: 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(), + "device": SmartRequest.device_info_list, "firmware": SmartRequest.firmware_info_list(), "quick_setup": [SmartRequest.qs_component_nego()], "inherit": [SmartRequest.get_raw_request("get_inherit_info")], @@ -325,33 +372,81 @@ COMPONENT_REQUESTS = { "schedule": SmartRequest.schedule_info_list(), "countdown": [SmartRequest.get_countdown_rules()], "antitheft": [SmartRequest.get_antitheft_rules()], - "account": None, - "synchronize": None, # sync_env - "sunrise_sunset": None, # for schedules + "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": None, - "device_local_time": None, - "default_states": None, # in device_info + "iot_cloud": [], + "device_local_time": [], + "default_states": [], # in device_info "auto_off": [SmartRequest.get_auto_off_config()], - "localSmart": None, + "localSmart": [], "energy_monitoring": SmartRequest.energy_monitoring_list(), "power_protection": SmartRequest.power_protection_list(), - "current_protection": None, # overcurrent in device_info - "matter": None, + "current_protection": [], # overcurrent in device_info + "matter": [SmartRequest.get_raw_request("get_matter_setup_info")], "preset": [SmartRequest.get_preset_rules()], - "brightness": None, # in device_info - "color": None, # in device_info - "color_temperature": None, # in device_info + "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": None, - "on_off_gradually": [SmartRequest.get_raw_request("get_on_off_gradually_info")], - "light_strip": None, + "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": None, # music_rhythm_enable in device_info + "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() + ) + ], + "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("getCleanRecords"), + SmartRequest.get_raw_request("getVacStatus"), + ], + "battery": [SmartRequest.get_raw_request("getBatteryInfo")], + "consumables": [SmartRequest.get_raw_request("getConsumablesInfo")], + "direction_control": [], + "button_and_led": [], + "speaker": [ + SmartRequest.get_raw_request("getSupportVoiceLanguage"), + SmartRequest.get_raw_request("getCurrentVoiceLanguage"), + ], + "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")], + "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 5e741623..f2189755 100644 --- a/devtools/parse_pcap.py +++ b/devtools/parse_pcap.py @@ -8,8 +8,8 @@ import click import dpkt from dpkt.ethernet import ETH_TYPE_IP, Ethernet -from kasa.cli import echo -from kasa.protocol import TPLinkSmartHomeProtocol +from kasa.cli.main import echo +from kasa.transports.xortransport import XorEncryption def read_payloads_from_file(file): @@ -34,7 +34,7 @@ def read_payloads_from_file(file): data = transport.data try: - decrypted = TPLinkSmartHomeProtocol.decrypt(data[4:]) + decrypted = XorEncryption.decrypt(data[4:]) except Exception as ex: echo(f"[red]Unable to decrypt the data, ignoring: {ex}[/red]") continue @@ -67,7 +67,7 @@ def parse_pcap(file): for module, cmds in json_payload.items(): seen_items["modules"][module] += 1 if "err_code" in cmds: - echo("[red]Got error for module: %s[/red]" % cmds) + echo(f"[red]Got error for module: {cmds}[/red]") continue for cmd, response in cmds.items(): diff --git a/devtools/parse_pcap_klap.py b/devtools/parse_pcap_klap.py new file mode 100755 index 00000000..0ddbed7f --- /dev/null +++ b/devtools/parse_pcap_klap.py @@ -0,0 +1,371 @@ +#!/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}, " + f"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 index 55c57f14..24c6b0e8 100644 --- a/devtools/perftest.py +++ b/devtools/perftest.py @@ -1,4 +1,5 @@ """Script for testing update performance on devices.""" + import asyncio import time diff --git a/devtools/update_fixtures.py b/devtools/update_fixtures.py new file mode 100644 index 00000000..13b9996e --- /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 00000000..3ebfbeb2 --- /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 b75cc85b..7d4eb080 100644 --- a/docs/source/cli.rst +++ b/docs/source/cli.rst @@ -51,6 +51,26 @@ You can provision your device without any extra apps by using the ``kasa wifi`` 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`` *************** diff --git a/docs/source/codeinfo.md b/docs/source/codeinfo.md new file mode 100644 index 00000000..3ee91b36 --- /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 01724943..03e44d95 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 ----------------------------------------------------- @@ -36,6 +37,10 @@ extensions = [ "myst_parser", ] +myst_enable_extensions = [ + "colon_fence", +] + # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] @@ -61,6 +66,6 @@ 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") diff --git a/docs/source/contribute.md b/docs/source/contribute.md new file mode 100644 index 00000000..8a060383 --- /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 00000000..f27c0985 --- /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/design.rst b/docs/source/design.rst deleted file mode 100644 index 6538c8b8..00000000 --- a/docs/source/design.rst +++ /dev/null @@ -1,72 +0,0 @@ -.. py:module:: kasa.modules - -.. _library_design: - -Library Design & Modules -======================== - -This page aims 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. - -.. contents:: Contents - :local: - -.. _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.SmartDevice.connect()`. - -The :meth:`~kasa.SmartDevice.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.SmartDevice.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.SmartDevice.connect()`. - - -.. _update_cycle: - -Update Cycle -************ - -When :meth:`~kasa.SmartDevice.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.SmartDevice.internal_state` property. - -.. _modules: - -Modules -******* - -The functionality provided by all :class:`~kasa.SmartDevice` instances is (mostly) done inside separate modules. -While the individual device-type specific classes provide an easy access for the most import features, -you can also access individual modules through :attr:`kasa.SmartDevice.modules`. -You can get the list of supported modules for a given device instance using :attr:`~kasa.SmartDevice.supported_modules`. - -.. note:: - - If you only need some module-specific information, - you can call the wanted method on the module to avoid using :meth:`~kasa.SmartDevice.update`. - - -API documentation for modules -***************************** - -.. automodule:: kasa.modules - :noindex: - :members: - :inherited-members: - :undoc-members: diff --git a/docs/source/discover.rst b/docs/source/discover.rst deleted file mode 100644 index b89178a3..00000000 --- a/docs/source/discover.rst +++ /dev/null @@ -1,62 +0,0 @@ -.. py:module:: kasa.discover - -Discovering devices -=================== - -.. contents:: Contents - :local: - -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:`SmartDevice.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 :meth:`Discover.discover() `, -you can provide a callback to the ``on_unsupported`` parameter -to handle these. - -Example: - -.. code-block:: python - - import asyncio - from kasa import Discover, Credentials - - async def main(): - device = await Discover.discover_single( - "127.0.0.1", - credentials=Credentials("myusername", "mypassword"), - discovery_timeout=10 - ) - - await device.update() # Request the update - print(device.alias) # Print out the alias - - devices = await Discover.discover( - credentials=Credentials("myusername", "mypassword"), - discovery_timeout=10 - ) - for ip, device in devices.items(): - await device.update() - print(device.alias) - - if __name__ == "__main__": - asyncio.run(main()) - -API documentation -***************** - -.. autoclass:: kasa.Discover - :members: - :undoc-members: diff --git a/docs/source/featureattributes.md b/docs/source/featureattributes.md new file mode 100644 index 00000000..69285ad4 --- /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 00000000..75b1424b --- /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 00000000..9336a1c1 --- /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 00000000..c2fbfb74 --- /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 00000000..2d50c4c6 --- /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 00000000..d7b5727c --- /dev/null +++ b/docs/source/guides/energy.md @@ -0,0 +1,27 @@ + +# Get 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 (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 00000000..307f52a6 --- /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 00000000..c8b72a99 --- /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 00000000..a001cf50 --- /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 00000000..d1377eab --- /dev/null +++ b/docs/source/guides/strip.md @@ -0,0 +1,10 @@ +(child_target)= +# Interact with child devices + +:::{include} ../codeinfo.md +::: + +```{eval-rst} +.. automodule:: kasa.smart.modules.childdevice + :noindex: +``` diff --git a/docs/source/index.md b/docs/source/index.md new file mode 100644 index 00000000..e1ba0833 --- /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 346c53d0..00000000 --- a/docs/source/index.rst +++ /dev/null @@ -1,17 +0,0 @@ -.. include:: ../../README.md - :parser: myst_parser.sphinx_ - -.. toctree:: - :maxdepth: 2 - - - Home - cli - discover - smartdevice - design - smartbulb - smartplug - smartdimmer - smartstrip - smartlightstrip diff --git a/docs/source/library.md b/docs/source/library.md new file mode 100644 index 00000000..fa276a1b --- /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 00000000..90493c9c --- /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 aa0e27e5..8fae54d1 100644 --- a/docs/source/smartbulb.rst +++ b/docs/source/smartbulb.rst @@ -67,13 +67,13 @@ API documentation :members: :undoc-members: -.. autoclass:: kasa.smartbulb.BehaviorMode +.. autoclass:: kasa.iot.iotbulb.BehaviorMode :members: -.. autoclass:: kasa.TurnOnBehaviors +.. autoclass:: kasa.iot.iotbulb.TurnOnBehaviors :members: -.. autoclass:: kasa.TurnOnBehavior +.. autoclass:: kasa.iot.iotbulb.TurnOnBehavior :undoc-members: :members: diff --git a/docs/source/smartdevice.rst b/docs/source/smartdevice.rst index 2a29a8d9..0f91642c 100644 --- a/docs/source/smartdevice.rst +++ b/docs/source/smartdevice.rst @@ -1,7 +1,7 @@ -.. py:module:: kasa +.. py:currentmodule:: kasa -Common API -========== +Base Device +=========== .. contents:: Contents :local: @@ -13,7 +13,7 @@ The basic functionalities of all supported devices are accessible using the comm 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:`library_design` for more detailed information. +See :ref:`topics-update-cycle` for more detailed information. .. note:: The device instances share the communication socket in background to optimize I/O accesses. @@ -26,7 +26,7 @@ These methods will return the device response, which can be useful for some use Errors are raised as :class:`SmartDeviceException` instances for the library user to handle. -Simple example script showing some functionality for legacy devices: +Simple example script showing some functionality: .. code-block:: python @@ -45,31 +45,6 @@ Simple example script showing some functionality for legacy devices: if __name__ == "__main__": asyncio.run(main()) -If you are connecting to a newer KASA or TAPO device you can get the device via discovery or -connect directly with :class:`DeviceConfig`: - -.. code-block:: python - - import asyncio - from kasa import Discover, Credentials - - async def main(): - device = await Discover.discover_single( - "127.0.0.1", - credentials=Credentials("myusername", "mypassword"), - discovery_timeout=10 - ) - - config = device.config # DeviceConfig.to_dict() can be used to store for later - - # To connect directly later without discovery - - later_device = await SmartDevice.connect(config=config) - - await later_device.update() - - print(later_device.alias) # Print out the alias - 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 @@ -92,22 +67,6 @@ Refer to device type specific classes for more examples: :class:`SmartPlug`, :class:`SmartBulb`, :class:`SmartStrip`, :class:`SmartDimmer`, :class:`SmartLightStrip`. -DeviceConfig class -****************** - -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:`SmartDevice.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:`SmartDevice.config` post discovery and then re-used. - Energy Consumption and Usage Statistics *************************************** @@ -144,25 +103,3 @@ API documentation .. autoclass:: SmartDevice :members: :undoc-members: - -.. autoclass:: DeviceConfig - :members: - :inherited-members: - :undoc-members: - :member-order: bysource - -.. autoclass:: Credentials - :members: - :undoc-members: - -.. autoclass:: SmartDeviceException - :members: - :undoc-members: - -.. autoclass:: AuthenticationException - :members: - :undoc-members: - -.. autoclass:: UnsupportedDeviceException - :members: - :undoc-members: diff --git a/docs/source/topics.md b/docs/source/topics.md new file mode 100644 index 00000000..f7d0cdd5 --- /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 00000000..30944dd5 --- /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 00000000..76094abb --- /dev/null +++ b/docs/tutorial.py @@ -0,0 +1,95 @@ +# 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 + +: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: \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\nDevice time: 2024-02-23 02:40:15+01:00 +""" diff --git a/kasa/__init__.py b/kasa/__init__.py index 3465147a..b8871f99 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -1,70 +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 +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 ( - ConnectionType, DeviceConfig, - DeviceFamilyType, - EncryptType, + DeviceConnectionParameters, + DeviceEncryptionType, + DeviceFamily, ) from kasa.discover import Discover from kasa.emeterstatus import EmeterStatus from kasa.exceptions import ( - AuthenticationException, - SmartDeviceException, - TimeoutException, - UnsupportedDeviceException, + AuthenticationError, + DeviceError, + KasaException, + TimeoutError, + UnsupportedDeviceError, ) -from kasa.iotprotocol import IotProtocol -from kasa.protocol import TPLinkProtocol, TPLinkSmartHomeProtocol -from kasa.smartbulb import SmartBulb, SmartBulbPreset, TurnOnBehavior, TurnOnBehaviors -from kasa.smartdevice import DeviceType, SmartDevice -from kasa.smartdimmer import SmartDimmer -from kasa.smartlightstrip import SmartLightStrip -from kasa.smartplug import SmartPlug -from kasa.smartprotocol import SmartProtocol -from kasa.smartstrip import SmartStrip +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", - "TPLinkProtocol", + "BaseProtocol", + "BaseTransport", "IotProtocol", "SmartProtocol", - "SmartBulb", - "SmartBulbPreset", + "SmartCamProtocol", + "LightState", "TurnOnBehaviors", "TurnOnBehavior", "DeviceType", + "Feature", "EmeterStatus", - "SmartDevice", - "SmartDeviceException", - "SmartPlug", - "SmartStrip", - "SmartDimmer", - "SmartLightStrip", - "AuthenticationException", - "UnsupportedDeviceException", - "TimeoutException", + "Device", + "Light", + "ColorTempRange", + "HSV", + "Plug", + "Module", + "KasaException", + "AuthenticationError", + "DeviceError", + "UnsupportedDeviceError", + "TimeoutError", "Credentials", "DeviceConfig", - "ConnectionType", - "EncryptType", - "DeviceFamilyType", + "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/aestransport.py b/kasa/aestransport.py deleted file mode 100644 index bdae00d0..00000000 --- a/kasa/aestransport.py +++ /dev/null @@ -1,366 +0,0 @@ -"""Implementation of the TP-Link AES transport. - -Based on the work of https://github.com/petretiandrea/plugp100 -under compatible GNU GPL3 license. -""" - -import base64 -import hashlib -import logging -import time -from typing import Optional - -import httpx -from cryptography.hazmat.primitives import 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 .credentials import Credentials -from .deviceconfig import DeviceConfig -from .exceptions import ( - SMART_AUTHENTICATION_ERRORS, - SMART_RETRYABLE_ERRORS, - SMART_TIMEOUT_ERRORS, - AuthenticationException, - RetryableException, - SmartDeviceException, - SmartErrorCode, - TimeoutException, -) -from .json import dumps as json_dumps -from .json import loads as json_loads -from .protocol import BaseTransport - -_LOGGER = logging.getLogger(__name__) - - -def _sha1(payload: bytes) -> str: - sha1_algo = hashlib.sha1() # noqa: S324 - sha1_algo.update(payload) - return sha1_algo.hexdigest() - - -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" - COMMON_HEADERS = { - "Content-Type": "application/json", - "requestByApp": "true", - "Accept": "application/json", - } - - 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() - else: - self._login_params = json_loads( - base64.b64decode(self._credentials_hash.encode()).decode() # type: ignore[union-attr] - ) - - self._default_http_client: Optional[httpx.AsyncClient] = None - - self._handshake_done = False - - self._encryption_session: Optional[AesEncyptionSession] = None - self._session_expire_at: Optional[float] = None - - self._session_cookie = None - - self._login_token = None - - _LOGGER.debug("Created AES transport for %s", self._host) - - @property - def default_port(self): - """Default port for the transport.""" - 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() - - @property - def _http_client(self) -> httpx.AsyncClient: - if self._config.http_client: - return self._config.http_client - if not self._default_http_client: - self._default_http_client = httpx.AsyncClient() - return self._default_http_client - - def _get_login_params(self): - """Get the login parameters based on the login_version.""" - un, pw = self.hash_credentials(self._login_version == 2) - password_field_name = "password2" if self._login_version == 2 else "password" - return {password_field_name: pw, "username": un} - - def hash_credentials(self, login_v2): - """Hash the credentials.""" - if login_v2: - un = base64.b64encode( - _sha1(self._credentials.username.encode()).encode() - ).decode() - pw = base64.b64encode( - _sha1(self._credentials.password.encode()).encode() - ).decode() - else: - un = base64.b64encode( - _sha1(self._credentials.username.encode()).encode() - ).decode() - pw = base64.b64encode(self._credentials.password.encode()).decode() - return un, pw - - async def client_post(self, url, params=None, data=None, json=None, headers=None): - """Send an http post request to the device.""" - response_data = None - cookies = None - if self._session_cookie: - cookies = httpx.Cookies() - cookies.set(self.SESSION_COOKIE_NAME, self._session_cookie) - self._http_client.cookies.clear() - resp = await self._http_client.post( - url, - params=params, - data=data, - json=json, - timeout=self._timeout, - cookies=cookies, - headers=self.COMMON_HEADERS, - ) - if resp.status_code == 200: - response_data = resp.json() - - return resp.status_code, response_data - - def _handle_response_error_code(self, resp_dict: dict, msg: str): - 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_TIMEOUT_ERRORS: - raise TimeoutException(msg, error_code=error_code) - if error_code in SMART_RETRYABLE_ERRORS: - raise RetryableException(msg, error_code=error_code) - if error_code in SMART_AUTHENTICATION_ERRORS: - self._handshake_done = False - self._login_token = None - raise AuthenticationException(msg, error_code=error_code) - raise SmartDeviceException(msg, error_code=error_code) - - async def send_secure_passthrough(self, request: str): - """Send encrypted message as passthrough.""" - url = f"http://{self._host}/app" - if self._login_token: - url += f"?token={self._login_token}" - - 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.client_post(url, json=passthrough_request) - # _LOGGER.debug(f"secure_passthrough response is {status_code}: {resp_dict}") - - if status_code != 200: - raise SmartDeviceException( - 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" - ) - - response = self._encryption_session.decrypt( # type: ignore - resp_dict["result"]["response"].encode() - ) - resp_dict = json_loads(response) - return resp_dict - - async def perform_login(self): - """Login to the device.""" - self._login_token = None - login_request = { - "method": "login_device", - "params": self._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") - self._login_token = resp_dict["result"]["token"] - - async def perform_handshake(self): - """Perform the handshake.""" - _LOGGER.debug("Will perform handshaking...") - _LOGGER.debug("Generating keypair") - - self._handshake_done = False - self._session_expire_at = None - self._session_cookie = None - - url = f"http://{self._host}/app" - key_pair = KeyPair.create_key_pair() - - pub_key = ( - "-----BEGIN PUBLIC KEY-----\n" - + key_pair.get_public_key() - + "\n-----END PUBLIC KEY-----\n" - ) - handshake_params = {"key": pub_key} - _LOGGER.debug(f"Handshake params: {handshake_params}") - - request_body = {"method": "handshake", "params": handshake_params} - - _LOGGER.debug(f"Request {request_body}") - - status_code, resp_dict = await self.client_post(url, json=request_body) - - _LOGGER.debug(f"Device responded with: {resp_dict}") - - if status_code != 200: - raise SmartDeviceException( - f"{self._host} responded with an unexpected " - + f"status code {status_code} to handshake" - ) - - self._handle_response_error_code(resp_dict, "Unable to complete handshake") - - handshake_key = resp_dict["result"]["key"] - - self._session_cookie = self._http_client.cookies.get( # type: ignore - self.SESSION_COOKIE_NAME - ) - if not self._session_cookie: - self._session_cookie = self._http_client.cookies.get( # type: ignore - "SESSIONID" - ) - - self._session_expire_at = time.time() + 86400 - self._encryption_session = AesEncyptionSession.create_from_keypair( - handshake_key, key_pair - ) - - self._handshake_done = True - - _LOGGER.debug("Handshake with %s complete", self._host) - - def _handshake_session_expired(self): - """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): - """Send the request.""" - if not self._handshake_done or self._handshake_session_expired(): - await self.perform_handshake() - if not self._login_token: - await self.perform_login() - - return await self.send_secure_passthrough(request) - - async def close(self) -> None: - """Close the protocol.""" - client = self._default_http_client - self._default_http_client = None - self._handshake_done = False - self._login_token = None - if client: - await client.aclose() - - -class AesEncyptionSession: - """Class for an AES encryption session.""" - - @staticmethod - def create_from_keypair(handshake_key: str, keypair): - """Create the encryption session.""" - handshake_key_bytes: bytes = base64.b64decode(handshake_key.encode("UTF-8")) - private_key_data = base64.b64decode(keypair.get_private_key().encode("UTF-8")) - - private_key = serialization.load_der_private_key(private_key_data, None, None) - key_and_iv = private_key.decrypt( - handshake_key_bytes, asymmetric_padding.PKCS1v15() - ) - if key_and_iv is None: - raise ValueError("Decryption failed!") - - return AesEncyptionSession(key_and_iv[:16], key_and_iv[16:]) - - def __init__(self, key, iv): - self.cipher = Cipher(algorithms.AES(key), modes.CBC(iv)) - self.padding_strategy = padding.PKCS7(algorithms.AES.block_size) - - def encrypt(self, data) -> 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: - """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): - """Create a key pair.""" - private_key = rsa.generate_private_key(public_exponent=65537, key_size=key_size) - public_key = private_key.public_key() - - private_key_bytes = private_key.private_bytes( - encoding=serialization.Encoding.DER, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption(), - ) - public_key_bytes = public_key.public_bytes( - encoding=serialization.Encoding.DER, - format=serialization.PublicFormat.SubjectPublicKeyInfo, - ) - - return KeyPair( - private_key=base64.b64encode(private_key_bytes).decode("UTF-8"), - public_key=base64.b64encode(public_key_bytes).decode("UTF-8"), - ) - - def __init__(self, private_key: str, public_key: str): - self.private_key = private_key - self.public_key = public_key - - def get_private_key(self) -> str: - """Get the private key.""" - return self.private_key - - def get_public_key(self) -> str: - """Get the public key.""" - return self.public_key diff --git a/kasa/cachedzoneinfo.py b/kasa/cachedzoneinfo.py new file mode 100644 index 00000000..f3f5f441 --- /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 28227316..00000000 --- a/kasa/cli.py +++ /dev/null @@ -1,1048 +0,0 @@ -"""python-kasa cli tool.""" -import asyncio -import json -import logging -import re -import sys -from functools import singledispatch, wraps -from pprint import pformat as pf -from typing import Any, Dict, cast - -import asyncclick as click - -from kasa import ( - AuthenticationException, - ConnectionType, - Credentials, - DeviceConfig, - DeviceFamilyType, - Discover, - EncryptType, - SmartBulb, - SmartDevice, - SmartDimmer, - SmartLightStrip, - SmartPlug, - SmartStrip, - UnsupportedDeviceException, -) -from kasa.discover import DiscoveryResult - -try: - from pydantic.v1 import ValidationError -except ImportError: - from pydantic import ValidationError - -try: - from rich import print as _do_echo -except ImportError: - - def _strip_rich_formatting(echo_func): - """Strip rich formatting from messages.""" - - @wraps(echo_func) - def wrapper(message=None, *args, **kwargs): - if message is not None: - message = re.sub(r"\[/?.+?]", "", message) - echo_func(message, *args, **kwargs) - - return wrapper - - _do_echo = _strip_rich_formatting(click.echo) - -# echo is set to _do_echo so that it can be reset to _do_echo later after -# --json has set it to _nop_echo -echo = _do_echo - - -TYPE_TO_CLASS = { - "plug": SmartPlug, - "bulb": SmartBulb, - "dimmer": SmartDimmer, - "strip": SmartStrip, - "lightstrip": SmartLightStrip, -} - -ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in EncryptType] - -DEVICE_FAMILY_TYPES = [ - device_family_type.value for device_family_type in DeviceFamilyType -] - -click.anyio_backend = "asyncio" - -pass_dev = click.make_pass_decorator(SmartDevice) - - -class ExceptionHandlerGroup(click.Group): - """Group to capture all exceptions and print them nicely. - - Idea from https://stackoverflow.com/a/44347763 - """ - - def __call__(self, *args, **kwargs): - """Run the coroutine in the event loop and print any exceptions.""" - try: - asyncio.get_event_loop().run_until_complete(self.main(*args, **kwargs)) - except Exception as ex: - echo(f"Got error: {ex!r}") - - -def json_formatter_cb(result, **kwargs): - """Format and output the result as JSON, if requested.""" - if not kwargs.get("json"): - return - - @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(SmartDevice) - def _device_to_serializable(val: SmartDevice): - """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) - - -@click.group( - invoke_without_command=True, - cls=ExceptionHandlerGroup, - 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="255.255.255.255", - 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(list(TYPE_TO_CLASS), case_sensitive=False), -) -@click.option( - "--json/--no-json", - envvar="KASA_JSON", - default=False, - is_flag=True, - help="Output raw device response as JSON.", -) -@click.option( - "--encrypt-type", - envvar="KASA_ENCRYPT_TYPE", - default=None, - type=click.Choice(ENCRYPT_TYPES, case_sensitive=False), -) -@click.option( - "--device-family", - envvar="KASA_DEVICE_FAMILY", - default=None, - type=click.Choice(DEVICE_FAMILY_TYPES, case_sensitive=False), -) -@click.option( - "--login-version", - envvar="KASA_LOGIN_VERSION", - default=None, - type=int, -) -@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=3, - 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, - 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 sys.argv[-1] == "--help": - # Context object is required to avoid crashing on sub-groups - ctx.obj = SmartDevice(None) - return - - # If JSON output is requested, disable echo - global echo - if json: - - def _nop_echo(*args, **kwargs): - pass - - echo = _nop_echo - else: - # Set back to default is required if running tests with CliRunner - global _do_echo - echo = _do_echo - - 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 alias is not None and host is None: - echo(f"Alias is given, using discovery to find host {alias}") - host = await find_host_from_alias(alias=alias, target=target) - if host: - echo(f"Found hostname is {host}") - else: - echo(f"No device with name {alias} found") - return - - if bool(password) != bool(username): - raise click.BadOptionUsage( - "username", "Using authentication requires both --username and --password" - ) - - if username: - credentials = Credentials(username=username, password=password) - else: - credentials = None - - if host is None: - echo("No host name given, trying discovery..") - return await ctx.invoke(discover) - - if type is not None: - dev = TYPE_TO_CLASS[type](host) - await dev.update() - elif device_family and encrypt_type: - ctype = ConnectionType( - DeviceFamilyType(device_family), - EncryptType(encrypt_type), - login_version, - ) - config = DeviceConfig( - host=host, - credentials=credentials, - credentials_hash=credentials_hash, - timeout=timeout, - connection_type=ctype, - ) - dev = await SmartDevice.connect(config=config) - else: - echo("No --type or --device-family and --encrypt-type defined, discovering..") - dev = await Discover.discover_single( - host, - port=port, - credentials=credentials, - ) - await dev.update() - - ctx.obj = dev - - if ctx.invoked_subcommand is None: - return 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.""" - 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", prompt=True) -@click.option("--password", prompt=True, hide_input=True) -@pass_dev -async def join(dev: SmartDevice, ssid: str, password: str, keytype: str): - """Join the given wifi network.""" - echo(f"Asking the device to connect to {ssid}..") - res = await dev.wifi_join(ssid, password, keytype=keytype) - 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 - - -@cli.command() -@click.pass_context -async def discover(ctx): - """Discover devices in the network.""" - target = ctx.parent.params["target"] - username = ctx.parent.params["username"] - password = ctx.parent.params["password"] - verbose = ctx.parent.params["verbose"] - discovery_timeout = ctx.parent.params["discovery_timeout"] - timeout = ctx.parent.params["timeout"] - port = ctx.parent.params["port"] - - credentials = Credentials(username, password) if username and password else None - - sem = asyncio.Semaphore() - discovered = dict() - unsupported = [] - auth_failed = [] - - async def print_unsupported(unsupported_exception: UnsupportedDeviceException): - 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() - - echo(f"Discovering devices on {target} for {discovery_timeout} seconds") - - async def print_discovered(dev: SmartDevice): - async with sem: - try: - await dev.update() - except AuthenticationException: - auth_failed.append(dev._discovery_info) - echo("== Authentication failed for device ==") - _echo_discovery_info(dev._discovery_info) - echo() - else: - discovered[dev.host] = dev.internal_state - ctx.parent.obj = dev - await ctx.parent.invoke(state) - if verbose: - echo() - _echo_discovery_info(dev._discovery_info) - echo() - - await Discover.discover( - target=target, - discovery_timeout=discovery_timeout, - on_discovered=print_discovered, - on_unsupported=print_unsupported, - port=port, - timeout=timeout, - credentials=credentials, - ) - - 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 - - -def _echo_dictionary(discovery_info: dict): - 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): - if "system" in discovery_info and "get_sysinfo" in discovery_info["system"]: - _echo_dictionary(discovery_info["system"]["get_sysinfo"]) - return - - try: - dr = DiscoveryResult(**discovery_info) - except ValidationError: - _echo_dictionary(discovery_info) - return - - echo("\t[bold]== Discovery Result ==[/bold]") - echo(f"\tDevice Type: {dr.device_type}") - echo(f"\tDevice Model: {dr.device_model}") - echo(f"\tIP: {dr.ip}") - echo(f"\tMAC: {dr.mac}") - echo(f"\tDevice Id (hash): {dr.device_id}") - echo(f"\tOwner (hash): {dr.owner}") - echo(f"\tHW Ver: {dr.hw_ver}") - echo(f"\tIs Support IOT Cloud: {dr.is_support_iot_cloud})") - echo(f"\tOBD Src: {dr.obd_src}") - echo(f"\tFactory Default: {dr.factory_default}") - echo("\t\t== Encryption Scheme ==") - echo(f"\t\tEncrypt Type: {dr.mgt_encrypt_schm.encrypt_type}") - echo(f"\t\tIs Support HTTPS: {dr.mgt_encrypt_schm.is_support_https}") - echo(f"\t\tHTTP Port: {dr.mgt_encrypt_schm.http_port}") - echo(f"\t\tLV (Login Level): {dr.mgt_encrypt_schm.lv}") - - -async def find_host_from_alias(alias, target="255.255.255.255", timeout=1, attempts=3): - """Discover a device identified by its alias.""" - for _attempt in range(1, attempts): - found_devs = await Discover.discover(target=target, timeout=timeout) - for _ip, dev in found_devs.items(): - 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.""" - echo("== System info ==") - echo(pf(dev.sys_info)) - return dev.sys_info - - -@cli.command() -@pass_dev -@click.pass_context -async def state(ctx, dev: SmartDevice): - """Print out device state and versions.""" - verbose = ctx.parent.params.get("verbose", False) if ctx.parent else False - - echo(f"[bold]== {dev.alias} - {dev.model} ==[/bold]") - echo(f"\tHost: {dev.host}") - echo(f"\tPort: {dev.port}") - echo(f"\tDevice state: {dev.is_on}") - if dev.is_strip: - echo("\t[bold]== Plugs ==[/bold]") - for plug in dev.children: # type: ignore - echo(f"\t* Socket '{plug.alias}' state: {plug.is_on} since {plug.on_since}") - echo() - - echo("\t[bold]== Generic information ==[/bold]") - echo(f"\tTime: {dev.time} (tz: {dev.timezone}") - echo(f"\tHardware: {dev.hw_info['hw_ver']}") - echo(f"\tSoftware: {dev.hw_info['sw_ver']}") - echo(f"\tMAC (rssi): {dev.mac} ({dev.rssi})") - echo(f"\tLocation: {dev.location}") - - echo("\n\t[bold]== Device specific information ==[/bold]") - for info_name, info_data in dev.state_information.items(): - if isinstance(info_data, list): - echo(f"\t{info_name}:") - for item in info_data: - echo(f"\t\t{item}") - else: - echo(f"\t{info_name}: {info_data}") - - if dev.has_emeter: - echo("\n\t[bold]== Current State ==[/bold]") - emeter_status = dev.emeter_realtime - echo(f"\t{emeter_status}") - - echo("\n\t[bold]== Modules ==[/bold]") - for module in dev.modules.values(): - if module.is_supported: - echo(f"\t[green]+ {module}[/green]") - else: - echo(f"\t[red]- {module}[/red]") - - if verbose: - echo("\n\t[bold]== Verbose information ==[/bold]") - echo(f"\tCredentials hash: {dev.credentials_hash}") - echo(f"\tDevice ID: {dev.device_id}") - for feature in dev.features: - echo(f"\tFeature: {feature}") - return dev.internal_state - - -@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.""" - if index is not None: - if not dev.is_strip: - 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: - echo(f"Setting alias to {new_alias}") - res = await dev.set_alias(new_alias) - return res - - echo(f"Alias: {dev.alias}") - if dev.is_strip: - for plug in dev.children: - echo(f" * {plug.alias}") - - return dev.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) - echo(json.dumps(res)) - return res - - -@cli.command() -@pass_dev -@click.option("--index", type=int, required=False) -@click.option("--name", type=str, required=False) -@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, index: int, name: str, year, month, erase): - """Query emeter for historical consumption. - - Daily and monthly data provided in CSV format. - """ - if index is not None or name is not None: - if not dev.is_strip: - 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) - - echo("[bold]== Emeter ==[/bold]") - if not dev.has_emeter: - echo("Device has no emeter") - return - - if erase: - echo("Erasing emeter statistics..") - return await dev.erase_emeter_stats() - - if year: - echo(f"== For year {year.year} ==") - echo("Month, usage (kWh)") - usage_data = await dev.get_emeter_monthly(year=year.year) - elif month: - echo(f"== For month {month.month} of {month.year} ==") - 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 - if index is not None or name is not None: - emeter_status = await dev.get_emeter_realtime() - else: - emeter_status = dev.emeter_realtime - - echo("Current: %s A" % emeter_status["current"]) - echo("Voltage: %s V" % emeter_status["voltage"]) - echo("Power: %s W" % emeter_status["power"]) - echo("Total consumption: %s kWh" % emeter_status["total"]) - - echo("Today: %s kWh" % dev.emeter_today) - echo("This month: %s kWh" % dev.emeter_this_month) - - return emeter_status - - # output any detailed usage data - for index, usage in usage_data.items(): - echo(f"{index}, {usage}") - - return usage_data - - -@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 usage(dev: SmartDevice, year, month, erase): - """Query usage for historical consumption. - - Daily and monthly data provided in CSV format. - """ - echo("[bold]== Usage ==[/bold]") - 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("Today: %s minutes" % usage.usage_today) - echo("This month: %s minutes" % usage.usage_this_month) - - return usage - - # output any detailed usage data - for index, usage in usage_data.items(): - echo(f"{index}, {usage}") - - return usage_data - - -@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.""" - if not dev.is_dimmable: - echo("This device does not support brightness.") - return - - if brightness is None: - echo(f"Brightness: {dev.brightness}") - return dev.brightness - else: - echo(f"Setting brightness to {brightness}") - return 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.""" - if not dev.is_variable_color_temp: - echo("Device does not support color temperature") - return - - if temperature is None: - echo(f"Color temperature: {dev.color_temp}") - valid_temperature_range = dev.valid_temperature_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 dev.valid_temperature_range - else: - echo(f"Setting color temperature to {temperature}") - return await dev.set_color_temp(temperature, transition=transition) - - -@cli.command() -@click.argument("effect", type=click.STRING, default=None, required=False) -@click.pass_context -@pass_dev -async def effect(dev, ctx, effect): - """Set an effect.""" - if not dev.has_effects: - echo("Device does not support effects") - return - if effect is None: - raise click.BadArgumentUsage( - f"Setting an effect requires a named built-in effect: {dev.effect_list}", - ctx, - ) - if effect not in dev.effect_list: - raise click.BadArgumentUsage(f"Effect must be one of: {dev.effect_list}", ctx) - - echo(f"Setting Effect: {effect}") - return await dev.set_effect(effect) - - -@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.""" - if not dev.is_color: - echo("Device does not support colors") - return - - if h is None or s is None or v is None: - echo(f"Current HSV: {dev.hsv}") - return dev.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 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.""" - if state is not None: - echo(f"Turning led to {state}") - return await dev.set_led(state) - else: - echo(f"LED state: {dev.led}") - return dev.led - - -@cli.command() -@pass_dev -async def time(dev): - """Get the device time.""" - res = dev.time - echo(f"Current time: {res}") - return res - - -@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.""" - if index is not None or name is not None: - if not dev.is_strip: - 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) - - echo(f"Turning on {dev.alias}") - return 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.""" - if index is not None or name is not None: - if not dev.is_strip: - 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) - - echo(f"Turning off {dev.alias}") - return await dev.turn_off(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 toggle(dev: SmartDevice, index: int, name: str, transition: int): - """Toggle the device on/off.""" - if index is not None or name is not None: - if not dev.is_strip: - 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) - - 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) - - -@cli.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) - - -@cli.group() -@pass_dev -async def schedule(dev): - """Scheduling commands.""" - - -@schedule.command(name="list") -@pass_dev -@click.argument("type", default="schedule") -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: - echo(f"No rules of type {type}") - - return sched.rules - - -@schedule.command(name="delete") -@pass_dev -@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: - echo(f"No rule with id {id} was found") - - -@cli.group(invoke_without_command=True) -@click.pass_context -async def presets(ctx): - """List and modify bulb setting presets.""" - if ctx.invoked_subcommand is None: - return await ctx.invoke(presets_list) - - -@presets.command(name="list") -@pass_dev -def presets_list(dev: SmartBulb): - """List presets.""" - if not dev.is_bulb: - echo("Presets only supported on bulbs") - return - - for preset in dev.presets: - echo(preset) - - return dev.presets - - -@presets.command(name="modify") -@click.argument("index", type=int) -@click.option("--brightness", type=int) -@click.option("--hue", type=int) -@click.option("--saturation", type=int) -@click.option("--temperature", type=int) -@pass_dev -async def presets_modify( - dev: SmartBulb, index, brightness, hue, saturation, temperature -): - """Modify a preset.""" - for preset in dev.presets: - if preset.index == index: - break - else: - echo(f"No preset found for index {index}") - return - - if brightness is not None: - preset.brightness = brightness - if hue is not None: - preset.hue = hue - if saturation is not None: - preset.saturation = saturation - if temperature is not None: - preset.color_temp = temperature - - echo(f"Going to save preset: {preset}") - - return await dev.save_preset(preset) - - -@cli.command() -@pass_dev -@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: SmartBulb, type, last, preset): - """Modify bulb turn-on behavior.""" - 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) - - -@cli.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.""" - # Importing here as this is not really a public interface for now - from kasa.tapo import TapoDevice - - if not isinstance(dev, TapoDevice): - raise NotImplementedError( - "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) - - -if __name__ == "__main__": - cli() diff --git a/kasa/cli/__init__.py b/kasa/cli/__init__.py new file mode 100644 index 00000000..1d499165 --- /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 00000000..1cf92da1 --- /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 00000000..649df065 --- /dev/null +++ b/kasa/cli/common.py @@ -0,0 +1,241 @@ +"""Common cli module.""" + +from __future__ import annotations + +import json +import re +import sys +from collections.abc import Callable +from contextlib import contextmanager +from functools import singledispatch, update_wrapper, wraps +from typing import TYPE_CHECKING, Any, Final + +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) -> None: + """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 + + @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) + + +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) + + return _CommandCls diff --git a/kasa/cli/device.py b/kasa/cli/device.py new file mode 100644 index 00000000..0ef8a76f --- /dev/null +++ b/kasa/cli/device.py @@ -0,0 +1,211 @@ +"""Module for cli device commands.""" + +from __future__ import annotations + +from pprint import pformat as pf + +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" {dev.device_info.firmware_build}" + ) + 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 + + _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 00000000..2470434b --- /dev/null +++ b/kasa/cli/discover.py @@ -0,0 +1,342 @@ +"""Module for cli discovery commands.""" + +from __future__ import annotations + +import asyncio +from pprint import pformat as pf + +import asyncclick as click + +from kasa import ( + AuthenticationError, + Credentials, + Device, + Discover, + UnsupportedDeviceError, +) +from kasa.discover import ( + NEW_DISCOVERY_REDACTORS, + ConnectAttempt, + DiscoveredRaw, + DiscoveryResult, +) +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): + """Discover devices in the network.""" + if ctx.invoked_subcommand is None: + return await ctx.invoke(detail) + + +@discover.command() +@click.pass_context +async def detail(ctx): + """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: + async with sem: + try: + await dev.update() + except AuthenticationError: + 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, print_unsupported=print_unsupported + ) + if ctx.parent.parent.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, redact: bool): + """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): + """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'}" + ) + return await _discover( + ctx, + print_discovered=print_discovered, + print_unsupported=print_unsupported, + do_echo=False, + ) + + +async def _discover( + ctx, *, print_discovered=None, print_unsupported=None, print_raw=None, do_echo=True +): + params = ctx.parent.parent.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: + echo(f"Discovering device {host} for {discovery_timeout} seconds") + return 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 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, + ) + + for device in discovered_devices.values(): + await device.protocol.close() + + return discovered_devices + + +@discover.command() +@click.pass_context +async def config(ctx): + """Bypass udp discovery and try to show connection config for a device. + + Bypasses udp discovery and shows the parameters required to connect + directly to the device. + """ + params = ctx.parent.parent.params + 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 = connect_attempt + key_str = f"{prot.__name__} + {tran.__name__} + {dev.__name__}" + 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'}" + ) + 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) -> 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.owner) + _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 00000000..522dee7f --- /dev/null +++ b/kasa/cli/feature.py @@ -0,0 +1,142 @@ +"""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 + + value = ast.literal_eval(value) + 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/lazygroup.py b/kasa/cli/lazygroup.py new file mode 100644 index 00000000..a2858634 --- /dev/null +++ b/kasa/cli/lazygroup.py @@ -0,0 +1,72 @@ +"""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 00000000..a7785563 --- /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 00000000..fbcdf391 --- /dev/null +++ b/kasa/cli/main.py @@ -0,0 +1,434 @@ +"""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, + 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", + }, + 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 ctx.invoke(discover) + + device_updated = 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 + login_version = 2 + 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: + from .discover import discover + + dev = await ctx.invoke(discover) + if not dev: + error(f"Unable to create device for {host}") + + # 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)) + + if ctx.invoked_subcommand is None: + from .device import state + + return await ctx.invoke(state) + + +@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 00000000..7c9c7381 --- /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 00000000..e2cb4c16 --- /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 00000000..c383f769 --- /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/wifi.py b/kasa/cli/wifi.py new file mode 100644 index 00000000..924e83f1 --- /dev/null +++ b/kasa/cli/wifi.py @@ -0,0 +1,50 @@ +"""Module for cli wifi commands.""" + +from __future__ import annotations + +import asyncclick as click + +from kasa import ( + Device, +) + +from .common import ( + echo, + pass_dev, +) + + +@click.group() +@pass_dev +def wifi(dev) -> 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", prompt=True) +@click.option("--password", prompt=True, hide_input=True) +@pass_dev +async def join(dev: Device, ssid: str, password: str, keytype: str): + """Join the given wifi network.""" + echo(f"Asking the device to connect to {ssid}..") + res = await dev.wifi_join(ssid, password, keytype=keytype) + 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 index 3cc0b016..66dd1174 100644 --- a/kasa/credentials.py +++ b/kasa/credentials.py @@ -1,5 +1,8 @@ """Credentials class for username / passwords.""" +from __future__ import annotations + +import base64 from dataclasses import dataclass, field @@ -11,3 +14,18 @@ class Credentials: username: str = field(default="", repr=False) #: Password of the cloud account password: str = field(default="", repr=False) + + +def get_default_credentials(tuple: tuple[str, str]) -> Credentials: + """Return decoded default credentials.""" + un = base64.b64decode(tuple[0].encode()).decode() + pw = base64.b64decode(tuple[1].encode()).decode() + return Credentials(un, pw) + + +DEFAULT_CREDENTIALS = { + "KASA": ("a2FzYUB0cC1saW5rLm5ldA==", "a2FzYVNldHVw"), + "KASACAMERA": ("YWRtaW4=", "MjEyMzJmMjk3YTU3YTVhNzQzODk0YTBlNGE4MDFmYzM="), + "TAPO": ("dGVzdEB0cC1saW5rLm5ldA==", "dGVzdA=="), + "TAPOCAMERA": ("YWRtaW4=", "YWRtaW4="), +} diff --git a/kasa/device.py b/kasa/device.py new file mode 100644 index 00000000..36068232 --- /dev/null +++ b/kasa/device.py @@ -0,0 +1,602 @@ +"""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) +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 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 + key_type: int + # These are available only on softaponboarding + cipher_type: int | None = None + bssid: str | None = None + channel: int | None = None + rssi: int | None = None + + # For SMART devices + signal_level: 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 + 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 + + _deprecated_other_attributes = { + # light attributes + "is_color": (Module.Light, ["is_color"]), + "is_dimmable": (Module.Light, ["is_dimmable"]), + "is_variable_color_temp": (Module.Light, ["is_variable_color_temp"]), + "brightness": (Module.Light, ["brightness"]), + "set_brightness": (Module.Light, ["set_brightness"]), + "hsv": (Module.Light, ["hsv"]), + "set_hsv": (Module.Light, ["set_hsv"]), + "color_temp": (Module.Light, ["color_temp"]), + "set_color_temp": (Module.Light, ["set_color_temp"]), + "valid_temperature_range": (Module.Light, ["valid_temperature_range"]), + "has_effects": (Module.Light, ["has_effects"]), + "_deprecated_set_light_state": (Module.Light, ["has_effects"]), + # led attributes + "led": (Module.Led, ["led"]), + "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] + # 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 old mode 100755 new mode 100644 index 757f0c33..99654a0c --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -1,36 +1,52 @@ """Device creation via DeviceConfig.""" + +from __future__ import annotations + import logging import time -from typing import Any, Dict, Optional, Tuple, Type +from typing import Any -from .aestransport import AesTransport -from .deviceconfig import DeviceConfig -from .exceptions import SmartDeviceException, UnsupportedDeviceException -from .iotprotocol import IotProtocol -from .klaptransport import KlapTransport, KlapTransportV2 -from .protocol import ( - BaseTransport, - TPLinkProtocol, - TPLinkSmartHomeProtocol, - _XorTransport, +from .device import Device +from .device_type import DeviceType +from .deviceconfig import DeviceConfig, DeviceFamily +from .exceptions import KasaException, UnsupportedDeviceError +from .iot import ( + IotBulb, + IotCamera, + IotDevice, + IotDimmer, + IotLightStrip, + IotPlug, + IotStrip, + IotWallSwitch, ) -from .smartbulb import SmartBulb -from .smartdevice import SmartDevice -from .smartdimmer import SmartDimmer -from .smartlightstrip import SmartLightStrip -from .smartplug import SmartPlug -from .smartprotocol import SmartProtocol -from .smartstrip import SmartStrip -from .tapo import TapoBulb, TapoPlug +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 = { - "system": {"get_sysinfo": None}, +GET_SYSINFO_QUERY: dict[str, dict[str, dict]] = { + "system": {"get_sysinfo": {}}, } -async def connect(*, host: Optional[str] = None, config: DeviceConfig) -> "SmartDevice": +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 @@ -50,33 +66,48 @@ async def connect(*, host: Optional[str] = None, config: DeviceConfig) -> "Smart :return: Object for querying/controlling found device. """ if host and config or (not host and not config): - raise SmartDeviceException("One of host or config must be provded and not both") + 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, perf_type): + def _perf_log(has_params: bool, perf_type: str) -> None: nonlocal start_time if debug_enabled: end_time = time.perf_counter() _LOGGER.debug( - f"Device {config.host} with connection params {has_params} " - + f"took {end_time - start_time:.2f} seconds to {perf_type}", + "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() - if (protocol := get_protocol(config=config)) is None: - raise UnsupportedDeviceException( - f"Unsupported device for {config.host}: " - + f"{config.connection_type.device_family.value}" - ) + device_class: type[Device] | None + device: Device | None = None - device_class: Optional[Type[SmartDevice]] - - if isinstance(protocol, TPLinkSmartHomeProtocol): + 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) @@ -86,79 +117,106 @@ async def connect(*, host: Optional[str] = None, config: DeviceConfig) -> "Smart _perf_log(True, "update") return device elif device_class := get_device_class_from_family( - config.connection_type.device_family.value + 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 UnsupportedDeviceException( + raise UnsupportedDeviceError( f"Unsupported device for {config.host}: " - + f"{config.connection_type.device_family.value}" + + f"{config.connection_type.device_family.value}", + host=config.host, ) -def get_device_class_from_sys_info(info: Dict[str, Any]) -> Type[SmartDevice]: +def get_device_class_from_sys_info(sysinfo: dict[str, Any]) -> type[IotDevice]: """Find SmartDevice subclass for device described by passed data.""" - if "system" not in info or "get_sysinfo" not in info["system"]: - raise SmartDeviceException("No 'system' or 'get_sysinfo' in response") - - sysinfo: Dict[str, Any] = info["system"]["get_sysinfo"] - type_: Optional[str] = sysinfo.get("type", sysinfo.get("mic_type")) - if type_ is None: - raise SmartDeviceException("Unable to find the device type field!") - - if "dev_name" in sysinfo and "Dimmer" in sysinfo["dev_name"]: - return SmartDimmer - - if "smartplug" in type_.lower(): - if "children" in sysinfo: - return SmartStrip - - return SmartPlug - - if "smartbulb" in type_.lower(): - if "length" in sysinfo: # strips have length - return SmartLightStrip - - return SmartBulb - raise UnsupportedDeviceException("Unknown device type: %s" % type_) - - -def get_device_class_from_family(device_type: str) -> Optional[Type[SmartDevice]]: - """Return the device class from the type name.""" - supported_device_types: Dict[str, Type[SmartDevice]] = { - "SMART.TAPOPLUG": TapoPlug, - "SMART.TAPOBULB": TapoBulb, - "SMART.KASAPLUG": TapoPlug, - "SMART.KASASWITCH": TapoBulb, - "IOT.SMARTPLUGSWITCH": SmartPlug, - "IOT.SMARTBULB": SmartBulb, + TYPE_TO_CLASS = { + DeviceType.Bulb: IotBulb, + DeviceType.Plug: IotPlug, + DeviceType.Dimmer: IotDimmer, + DeviceType.Strip: IotStrip, + DeviceType.WallSwitch: IotWallSwitch, + DeviceType.LightStrip: IotLightStrip, + DeviceType.Camera: IotCamera, } - return supported_device_types.get(device_type) + 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.TAPOROBOVAC": SmartDevice, + "IOT.SMARTPLUGSWITCH": IotPlug, + "IOT.SMARTBULB": IotBulb, + "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 + + return cls def get_protocol( config: DeviceConfig, -) -> Optional[TPLinkProtocol]: - """Return the protocol from the connection name.""" - protocol_name = config.connection_type.device_family.value.split(".")[0] +) -> BaseProtocol | None: + """Return the protocol from the connection name. + + 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. + """ + ctype = config.connection_type + protocol_name = ctype.device_family.value.split(".")[0] + + if ctype.device_family is DeviceFamily.SmartIpCamera: + return SmartCamProtocol(transport=SslAesTransport(config=config)) + + if ctype.device_family is DeviceFamily.IotIpCamera: + return IotProtocol(transport=LinkieTransportV2(config=config)) + + if ctype.device_family is DeviceFamily.SmartTapoRobovac: + return SmartProtocol(transport=SslTransport(config=config)) + protocol_transport_key = ( - protocol_name + "." + config.connection_type.encryption_type.value + protocol_name + + "." + + ctype.encryption_type.value + + (".HTTPS" if ctype.https else "") ) - supported_device_protocols: Dict[ - str, Tuple[Type[TPLinkProtocol], Type[BaseTransport]] + + _LOGGER.debug("Finding transport for %s", protocol_transport_key) + supported_device_protocols: dict[ + str, tuple[type[BaseProtocol], type[BaseTransport]] ] = { - "IOT.XOR": (TPLinkSmartHomeProtocol, _XorTransport), + "IOT.XOR": (IotProtocol, XorTransport), "IOT.KLAP": (IotProtocol, KlapTransport), "SMART.AES": (SmartProtocol, AesTransport), "SMART.KLAP": (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 protocol_transport_key not in supported_device_protocols: + if not (prot_tran_cls := supported_device_protocols.get(protocol_transport_key)): return None - - protocol_class, transport_class = supported_device_protocols.get( - protocol_transport_key - ) # type: ignore - return protocol_class(transport=transport_class(config=config)) + 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 index 8373d730..7fe485d3 100755 --- a/kasa/device_type.py +++ b/kasa/device_type.py @@ -1,5 +1,6 @@ """TP-Link device types.""" +from __future__ import annotations from enum import Enum @@ -11,15 +12,20 @@ class DeviceType(Enum): Plug = "plug" Bulb = "bulb" Strip = "strip" + Camera = "camera" + WallSwitch = "wallswitch" StripSocket = "stripsocket" Dimmer = "dimmer" LightStrip = "lightstrip" - TapoPlug = "tapoplug" - TapoBulb = "tapobulb" + Sensor = "sensor" + Hub = "hub" + Fan = "fan" + Thermostat = "thermostat" + Vacuum = "vacuum" Unknown = "unknown" @staticmethod - def from_value(name: str) -> "DeviceType": + def from_value(name: str) -> DeviceType: """Return device type from string value.""" for device_type in DeviceType: if device_type.value == name: diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index 4ea255a4..d2fb3e45 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -1,18 +1,62 @@ -"""Module for holding connection parameters.""" -import logging -from dataclasses import asdict, dataclass, field, fields, is_dataclass -from enum import Enum -from typing import Dict, Optional, Union +"""Configuration for connecting directly to a device without discovery. -import httpx +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}, 'uses_http': True} + +>>> 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 SmartDeviceException +from .exceptions import KasaException +from .json import DataClassJSONMixin + +if TYPE_CHECKING: + from aiohttp import ClientSession _LOGGER = logging.getLogger(__name__) -class EncryptType(Enum): +class KeyPairDict(TypedDict): + """Class to represent a public/private key pair.""" + + private: str + public: str + + +class DeviceEncryptionType(Enum): """Encrypt type enum.""" Klap = "KLAP" @@ -20,158 +64,130 @@ class EncryptType(Enum): Xor = "XOR" -class DeviceFamilyType(Enum): +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" -def _dataclass_from_dict(klass, in_val): - if is_dataclass(klass): - fieldtypes = {f.name: f.type for f in fields(klass)} - val = {} - for dict_key in in_val: - if dict_key in fieldtypes and hasattr(fieldtypes[dict_key], "from_dict"): - val[dict_key] = fieldtypes[dict_key].from_dict(in_val[dict_key]) - else: - val[dict_key] = _dataclass_from_dict( - fieldtypes[dict_key], in_val[dict_key] - ) - return klass(**val) - else: - return in_val +class _DeviceConfigBaseMixin(DataClassJSONMixin): + """Base class for serialization mixin.""" + class Config(BaseConfig): + """Serialization config.""" -def _dataclass_to_dict(in_val): - fieldtypes = {f.name: f.type for f in fields(in_val) if f.compare} - out_val = {} - for field_name in fieldtypes: - val = getattr(in_val, field_name) - if val is None: - continue - elif hasattr(val, "to_dict"): - out_val[field_name] = val.to_dict() - elif is_dataclass(fieldtypes[field_name]): - out_val[field_name] = asdict(val) - else: - out_val[field_name] = val - return out_val + omit_none = True @dataclass -class ConnectionType: +class DeviceConnectionParameters(_DeviceConfigBaseMixin): """Class to hold the the parameters determining connection type.""" - device_family: DeviceFamilyType - encryption_type: EncryptType - login_version: Optional[int] = None + device_family: DeviceFamily + encryption_type: DeviceEncryptionType + login_version: int | None = None + https: bool = False @staticmethod def from_values( device_family: str, encryption_type: str, - login_version: Optional[int] = None, - ) -> "ConnectionType": + login_version: int | None = None, + https: bool | None = None, + ) -> DeviceConnectionParameters: """Return connection parameters from string values.""" try: - return ConnectionType( - DeviceFamilyType(device_family), - EncryptType(encryption_type), + if https is None: + https = False + return DeviceConnectionParameters( + DeviceFamily(device_family), + DeviceEncryptionType(encryption_type), login_version, + https, ) except (ValueError, TypeError) as ex: - raise SmartDeviceException( + raise KasaException( f"Invalid connection parameters for {device_family}." + f"{encryption_type}.{login_version}" ) from ex - @staticmethod - def from_dict(connection_type_dict: Dict[str, str]) -> "ConnectionType": - """Return connection parameters from dict.""" - if ( - isinstance(connection_type_dict, dict) - and (device_family := connection_type_dict.get("device_family")) - and (encryption_type := connection_type_dict.get("encryption_type")) - ): - if login_version := connection_type_dict.get("login_version"): - login_version = int(login_version) # type: ignore[assignment] - return ConnectionType.from_values( - device_family, - encryption_type, - login_version, # type: ignore[arg-type] - ) - - raise SmartDeviceException( - f"Invalid connection type data for {connection_type_dict}" - ) - - def to_dict(self) -> Dict[str, Union[str, int]]: - """Convert connection params to dict.""" - result: Dict[str, Union[str, int]] = { - "device_family": self.device_family.value, - "encryption_type": self.encryption_type.value, - } - if self.login_version: - result["login_version"] = self.login_version - return result - @dataclass -class DeviceConfig: +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: Optional[int] = DEFAULT_TIMEOUT + timeout: int | None = DEFAULT_TIMEOUT #: Override the default 9999 port to support port forwarding - port_override: Optional[int] = None + port_override: int | None = None #: Credentials for devices requiring authentication - credentials: Optional[Credentials] = None + 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:`SmartDevice.credentials_hash` - credentials_hash: Optional[str] = None + #: 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. - connection_type: ConnectionType = field( - default_factory=lambda: ConnectionType( - DeviceFamilyType.IotSmartPlugSwitch, EncryptType.Xor, 1 + 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 ) ) #: True if the device uses http. Consumers should retrieve rather than set this #: in order to determine whether they should pass a custom http client if desired. uses_http: bool = False - # compare=False will be excluded from the serialization and object comparison. #: Set a custom http_client for the device to use. - http_client: Optional[httpx.AsyncClient] = field(default=None, compare=False) + http_client: ClientSession | None = field( + default=None, + compare=False, + metadata=field_options(serialize="omit", deserialize=pass_through), + ) - def __post_init__(self): + aes_keys: KeyPairDict | None = None + + def __post_init__(self) -> None: if self.connection_type is None: - self.connection_type = ConnectionType( - DeviceFamilyType.IotSmartPlugSwitch, EncryptType.Xor + self.connection_type = DeviceConnectionParameters( + DeviceFamily.IotSmartPlugSwitch, DeviceEncryptionType.Xor ) - def to_dict( + def to_dict_control_credentials( self, *, - credentials_hash: Optional[str] = None, + credentials_hash: str | None = None, exclude_credentials: bool = False, - ) -> Dict[str, Dict[str, str]]: - """Convert device config to dict.""" - if credentials_hash is not None or exclude_credentials: - self.credentials = None - if credentials_hash: - self.credentials_hash = credentials_hash - return _dataclass_to_dict(self) + ) -> dict[str, dict[str, str]]: + """Convert deviceconfig to dict controlling how to serialize credentials. - @staticmethod - def from_dict(cparam_dict: Dict[str, Dict[str, str]]) -> "DeviceConfig": - """Return device config from dict.""" - return _dataclass_from_dict(DeviceConfig, cparam_dict) + 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 fca578a3..77ef80be 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -1,38 +1,231 @@ -"""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'] + +You can pass username and password for devices requiring authentication + +>>> devices = await Discover.discover( +>>> username="user@example.com", +>>> password="great_password", +>>> ) +>>> print(len(devices)) +5 + +You can also pass a :class:`kasa.Credentials` + +>>> creds = Credentials("user@example.com", "great_password") +>>> devices = await Discover.discover(credentials=creds) +>>> print(len(devices)) +5 + +Discovery can also be targeted to a specific broadcast address instead of +the default 255.255.255.255: + +>>> found_devices = await Discover.discover(target="127.0.0.255", credentials=creds) +>>> print(len(found_devices)) +5 + +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) + +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 base64 import binascii import ipaddress import logging +import secrets import socket -from typing import Awaitable, Callable, Dict, Optional, Set, Type, cast +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, +) -# When support for cpython older than 3.11 is dropped -# async_timeout can be replaced with asyncio.timeout -from async_timeout import timeout as asyncio_timeout - -try: - from pydantic.v1 import BaseModel, ValidationError # pragma: no cover -except ImportError: - from pydantic import BaseModel, ValidationError # pragma: no cover +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 ConnectionType, DeviceConfig, EncryptType -from kasa.exceptions import TimeoutException, UnsupportedDeviceException +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.protocol import TPLinkSmartHomeProtocol -from kasa.smartdevice import SmartDevice, SmartDeviceException +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 -OnDiscoveredCallable = Callable[[SmartDevice], Awaitable[None]] -DeviceDict = Dict[str, SmartDevice] + +class ConnectAttempt(NamedTuple): + """Try to connect attempt.""" + + protocol: type + transport: type + device: type + + +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, + ) + + 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): @@ -41,78 +234,121 @@ class _DiscoverProtocol(asyncio.DatagramProtocol): This is internal class, use :func:`Discover.discover`: instead. """ + DISCOVERY_START_TIMEOUT = 1 + discovered_devices: DeviceDict def __init__( self, *, - on_discovered: Optional[OnDiscoveredCallable] = None, + on_discovered: OnDiscoveredCallable | None = None, + on_discovered_raw: OnDiscoveredRawCallable | None = None, target: str = "255.255.255.255", discovery_packets: int = 3, - interface: Optional[str] = None, - on_unsupported: Optional[ - Callable[[UnsupportedDeviceException], Awaitable[None]] - ] = None, - port: Optional[int] = None, - discovered_event: Optional[asyncio.Event] = None, - credentials: Optional[Credentials] = None, - timeout: Optional[int] = None, + 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 = None + self.transport: DatagramTransport | None = None self.discovery_packets = discovery_packets self.interface = interface self.on_discovered = on_discovered self.port = port self.discovery_port = port or Discover.DISCOVERY_PORT - self.target = (target, self.discovery_port) + self.target = target + self.target_1 = (target, self.discovery_port) self.target_2 = (target, Discover.DISCOVERY_PORT_2) self.discovered_devices = {} - self.unsupported_device_exceptions: Dict = {} - self.invalid_device_exceptions: Dict = {} + self.unsupported_device_exceptions: dict = {} + self.invalid_device_exceptions: dict = {} self.on_unsupported = on_unsupported - self.discovered_event = discovered_event + self.on_discovered_raw = on_discovered_raw self.credentials = credentials self.timeout = timeout - self.seen_hosts: Set[str] = set() + 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 connection_made(self, transport) -> None: + 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 + self.transport = cast(DatagramTransport, transport) - sock = transport.get_extra_info("socket") + sock = self.transport.get_extra_info("socket") sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 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) - if self.interface is not None: + # 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) _LOGGER.debug("[DISCOVERY] %s >> %s", self.target, Discover.DISCOVERY_QUERY) - encrypted_req = TPLinkSmartHomeProtocol.encrypt(req) - for _i in range(self.discovery_packets): - self.transport.sendto(encrypted_req[4:], self.target) # type: ignore - self.transport.sendto(Discover.DISCOVERY_QUERY_2, self.target_2) # type: ignore + encrypted_req = XorEncryption.encrypt(req) + sleep_between_packets = self.discovery_timeout / self.discovery_packets - def datagram_received(self, data, addr) -> None: + 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 + 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 # Prevent multiple entries due multiple broadcasts if ip in self.seen_hosts: return self.seen_hosts.add(ip) - device = None + device: Device | None = None config = DeviceConfig(host=ip, port_override=self.port) if self.credentials: @@ -121,105 +357,89 @@ class _DiscoverProtocol(asyncio.DatagramProtocol): config.timeout = self.timeout try: if port == self.discovery_port: - device = Discover._get_device_instance_legacy(data, config) + json_func = Discover._get_discovery_json_legacy + device_func = Discover._get_device_instance_legacy elif port == Discover.DISCOVERY_PORT_2: config.uses_http = True - device = Discover._get_device_instance(data, config) + json_func = Discover._get_discovery_json + device_func = Discover._get_device_instance else: return - except UnsupportedDeviceException as udex: + 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: - asyncio.ensure_future(self.on_unsupported(udex)) - if self.discovered_event is not None: - self.discovered_event.set() + self._run_callback_task(self.on_unsupported(udex)) + self._handle_discovered_event() return - except SmartDeviceException as ex: - _LOGGER.debug(f"[DISCOVERY] Unable to find device type for {ip}: {ex}") + except KasaException as ex: + _LOGGER.debug("[DISCOVERY] Unable to find device type for %s: %s", ip, ex) self.invalid_device_exceptions[ip] = ex - if self.discovered_event is not None: - self.discovered_event.set() + self._handle_discovered_event() return self.discovered_devices[ip] = device if self.on_discovered is not None: - asyncio.ensure_future(self.on_discovered(device)) + self._run_callback_task(self.on_discovered(device)) - if self.discovered_event is not None: - self.discovered_event.set() + 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 :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. - - 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 default 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}, + DISCOVERY_QUERY: dict[str, dict[str, dict]] = { + "system": {"get_sysinfo": {}}, } DISCOVERY_PORT_2 = 20002 DISCOVERY_QUERY_2 = binascii.unhexlify("020000010000000000000000463cb5d3") + _redact_data = True + @staticmethod async def discover( *, - target="255.255.255.255", - on_discovered=None, - discovery_timeout=5, - discovery_packets=3, - interface=None, - on_unsupported=None, - credentials=None, - port=None, - timeout=None, + 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. @@ -231,25 +451,32 @@ class Discover: you can use *target* parameter to specify the network for discovery. If given, `on_discovered` coroutine will get awaited with - a :class:`SmartDevice`-derived object as parameter. + a :class:`Device`-derived object as parameter. The results of the discovery are returned as a dict of - :class:`SmartDevice`-derived objects keyed with IP addresses. + :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 address where to send the broadcast discovery queries if multi-homing (e.g. 192.168.xxx.255). :param on_discovered: coroutine to execute on discovery + :param on_discovered_raw: Optional callback once discovered json is loaded + before any attempt to deserialize it and create devices :param discovery_timeout: Seconds to wait for responses, defaults to 5 :param discovery_packets: Number of discovery packets to broadcast :param interface: Bind to specific interface :param on_unsupported: Optional callback when unsupported devices are discovered - :param credentials: Credentials for devices requiring authentication + :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( @@ -258,8 +485,10 @@ class Discover: discovery_packets=discovery_packets, interface=interface, on_unsupported=on_unsupported, + on_discovered_raw=on_discovered_raw, credentials=credentials, timeout=timeout, + discovery_timeout=discovery_timeout, port=port, ), local_addr=("0.0.0.0", 0), # noqa: S104 @@ -268,7 +497,11 @@ class Discover: try: _LOGGER.debug("Waiting %s seconds for responses...", discovery_timeout) - await asyncio.sleep(discovery_timeout) + await protocol.wait_for_discovery_to_complete() + except KasaException as ex: + for device in protocol.discovered_devices.values(): + await device.protocol.close() + raise ex finally: transport.close() @@ -281,14 +514,18 @@ class Discover: host: str, *, discovery_timeout: int = 5, - port: Optional[int] = None, - timeout: Optional[int] = None, - credentials: Optional[Credentials] = None, - ) -> SmartDevice: + 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:`SmartDevice.connect()` instead as it should perform better when + 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. @@ -296,12 +533,19 @@ class Discover: :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 + :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. """ + if not credentials and username and password: + credentials = Credentials(username, password) loop = asyncio.get_event_loop() - event = asyncio.Event() try: ipaddress.ip_address(host) @@ -323,17 +567,16 @@ class Discover: # https://docs.python.org/3/library/socket.html#socket.getaddrinfo ip = adrrinfo[0][4][0] except socket.gaierror as gex: - raise SmartDeviceException( - f"Could not resolve hostname {host}" - ) from gex + raise KasaException(f"Could not resolve hostname {host}") from gex transport, protocol = await loop.create_datagram_endpoint( lambda: _DiscoverProtocol( target=ip, port=port, - discovered_event=event, credentials=credentials, timeout=timeout, + discovery_timeout=discovery_timeout, + on_discovered_raw=on_discovered_raw, ), local_addr=("0.0.0.0", 0), # noqa: S104 ) @@ -343,13 +586,7 @@ class Discover: _LOGGER.debug( "Waiting a total of %s seconds for responses...", discovery_timeout ) - - async with asyncio_timeout(discovery_timeout): - await event.wait() - except asyncio.TimeoutError as ex: - raise TimeoutException( - f"Timed out getting discovery response for {host}" - ) from ex + await protocol.wait_for_discovery_to_complete() finally: transport.close() @@ -358,21 +595,118 @@ class Discover: dev.host = host return dev elif ip in protocol.unsupported_device_exceptions: - raise protocol.unsupported_device_exceptions[ip] + 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 SmartDeviceException(f"Unable to get discovery response for {host}") + raise TimeoutError(f"Timed out getting discovery response for {host}") @staticmethod - def _get_device_class(info: dict) -> Type[SmartDevice]: + 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, + } + candidates: dict[ + tuple[type[BaseProtocol], type[BaseTransport], type[Device]], + tuple[BaseProtocol, DeviceConfig], + ] = { + (type(protocol), type(protocol._transport), device_class): ( + 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, + uses_http=encrypt is not Device.EncryptionType.Xor, + ) + ) + and (protocol := get_protocol(config)) + 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 + dev = await _connect(config, prot) + except Exception: + _LOGGER.debug("Unable to connect with %s", prot) + 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) + return dev + finally: + await prot.close() + return None + + @staticmethod + def _get_device_class(info: dict) -> type[Device]: """Find SmartDevice subclass for device described by passed data.""" if "result" in info: - discovery_result = DiscoveryResult(**info["result"]) - dev_class = get_device_class_from_family(discovery_result.device_type) + 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 UnsupportedDeviceException( - "Unknown device type: %s" % discovery_result.device_type, + raise UnsupportedDeviceError( + f"Unknown device type: {discovery_result.device_type}", discovery_result=info, ) return dev_class @@ -380,119 +714,256 @@ class Discover: return get_device_class_from_sys_info(info) @staticmethod - def _get_device_instance_legacy(data: bytes, config: DeviceConfig) -> SmartDevice: - """Get SmartDevice from legacy 9999 response.""" + def _get_discovery_json_legacy(data: bytes, ip: str) -> dict: + """Get discovery json from legacy 9999 response.""" try: - info = json_loads(TPLinkSmartHomeProtocol.decrypt(data)) + info = json_loads(XorEncryption.decrypt(data)) except Exception as ex: - raise SmartDeviceException( - f"Unable to read response from device: {config.host}: {ex}" + raise KasaException( + f"Unable to read response from device: {ip}: {ex}" ) from ex + return info - _LOGGER.debug("[DISCOVERY] %s << %s", config.host, 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 = Discover._get_device_class(info) + device_class = cast(type[IotDevice], Discover._get_device_class(info)) device = device_class(config.host, config=config) - sys_info = info["system"]["get_sysinfo"] - if device_type := sys_info.get("mic_type", sys_info.get("type")): - config.connection_type = ConnectionType.from_values( - device_family=device_type, encryption_type=EncryptType.Xor.value - ) + 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 _get_device_instance( - data: bytes, - config: DeviceConfig, - ) -> SmartDevice: - """Get SmartDevice from the new 20002 response.""" + 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", config.host, data) - raise SmartDeviceException( - f"Unable to read response from device: {config.host}: {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_device_instance( + info: dict, + config: DeviceConfig, + ) -> Device: + """Get SmartDevice from the new 20002 response.""" + debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG) + try: - discovery_result = DiscoveryResult(**info["result"]) - except ValidationError as ex: - _LOGGER.debug( - "Unable to parse discovery from device %s: %s", config.host, info - ) - raise UnsupportedDeviceException( - f"Unable to parse discovery from device: {config.host}: {ex}" + 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 + if (encrypt_schm := discovery_result.mgt_encrypt_schm) is None: + raise UnsupportedDeviceError( + f"Unsupported device {config.host} of type {type_} " + "with no mgt_encrypt_schm", + discovery_result=discovery_result.to_dict(), + host=config.host, + ) try: - config.connection_type = ConnectionType.from_values( + if not (encrypt_type := encrypt_schm.encrypt_type) and ( + encrypt_info := discovery_result.encrypt_info + ): + encrypt_type = encrypt_info.sym_schm + + if not (login_version := encrypt_schm.lv) and ( + et := discovery_result.encrypt_type + ): + # Known encrypt types are ["1","2"] and ["3"] + # Reuse the login_version attribute to pass the max to transport + login_version = max([int(i) for i in et]) + + if not encrypt_type: + raise UnsupportedDeviceError( + f"Unsupported device {config.host} of type {type_} " + + "with no encryption type", + discovery_result=discovery_result.to_dict(), + host=config.host, + ) + config.connection_type = DeviceConnectionParameters.from_values( type_, - discovery_result.mgt_encrypt_schm.encrypt_type, - discovery_result.mgt_encrypt_schm.lv, + encrypt_type, + login_version, + encrypt_schm.is_support_https, ) - except SmartDeviceException as ex: - raise UnsupportedDeviceException( + except KasaException as ex: + raise UnsupportedDeviceError( f"Unsupported device {config.host} of type {type_} " - + f"with encrypt_type {discovery_result.mgt_encrypt_schm.encrypt_type}", - discovery_result=discovery_result.get_dict(), + + f"with encrypt_type {encrypt_schm.encrypt_type}", + discovery_result=discovery_result.to_dict(), + host=config.host, ) from ex - if (device_class := get_device_class_from_family(type_)) is None: - _LOGGER.warning("Got unsupported device type: %s", type_) - raise UnsupportedDeviceException( - f"Unsupported device {config.host} of type {type_}: {info}", - discovery_result=discovery_result.get_dict(), + + if ( + device_class := get_device_class_from_family( + type_, https=encrypt_schm.is_support_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.warning( + _LOGGER.debug( "Got unsupported connection type: %s", config.connection_type.to_dict() ) - raise UnsupportedDeviceException( + raise UnsupportedDeviceError( f"Unsupported encryption scheme {config.host} of " + f"type {config.connection_type.to_dict()}: {info}", - discovery_result=discovery_result.get_dict(), + discovery_result=discovery_result.to_dict(), + host=config.host, ) - _LOGGER.debug("[DISCOVERY] %s << %s", config.host, info) + 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.get_dict() + di = discovery_result.to_dict() di["model"], _, _ = discovery_result.device_model.partition("(") device.update_from_discover_info(di) return device -class DiscoveryResult(BaseModel): +class _DiscoveryBaseMixin(DataClassJSONMixin): + """Base class for serialization mixin.""" + + class Config(BaseConfig): + """Serialization config.""" + + omit_none = True + omit_default = True + serialize_by_alias = True + + +@dataclass +class EncryptionScheme(_DiscoveryBaseMixin): + """Base model for encryption scheme of discovery result.""" + + is_support_https: bool + encrypt_type: str | None = None + http_port: int | None = None + lv: int | None = None + + +@dataclass +class EncryptionInfo(_DiscoveryBaseMixin): + """Base model for encryption info of discovery result.""" + + sym_schm: str + key: str + data: str + + +@dataclass +class DiscoveryResult(_DiscoveryBaseMixin): """Base model for discovery result.""" - class EncryptionScheme(BaseModel): - """Base model for encryption scheme of discovery result.""" - - is_support_https: bool - encrypt_type: str - http_port: int - lv: Optional[int] = None - device_type: str device_model: str + device_id: str ip: str mac: str - mgt_encrypt_schm: EncryptionScheme - device_id: 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 - hw_ver: Optional[str] = None - owner: Optional[str] = None - is_support_iot_cloud: Optional[bool] = None - obd_src: Optional[str] = None - factory_default: Optional[bool] = None - - def get_dict(self) -> dict: - """Return a dict for this discovery result. - - containing only the values actually set and with aliases as field names. - """ - return self.dict( - by_alias=False, exclude_unset=True, exclude_none=True, exclude_defaults=True - ) + 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 index 48d6e241..acb87789 100644 --- a/kasa/emeterstatus.py +++ b/kasa/emeterstatus.py @@ -1,6 +1,8 @@ """Module for emeter container.""" + +from __future__ import annotations + import logging -from typing import Optional _LOGGER = logging.getLogger(__name__) @@ -16,7 +18,7 @@ class EmeterStatus(dict): """ @property - def voltage(self) -> Optional[float]: + def voltage(self) -> float | None: """Return voltage in V.""" try: return self["voltage"] @@ -24,7 +26,7 @@ class EmeterStatus(dict): return None @property - def power(self) -> Optional[float]: + def power(self) -> float | None: """Return power in W.""" try: return self["power"] @@ -32,7 +34,7 @@ class EmeterStatus(dict): return None @property - def current(self) -> Optional[float]: + def current(self) -> float | None: """Return current in A.""" try: return self["current"] @@ -40,20 +42,20 @@ class EmeterStatus(dict): return None @property - def total(self) -> Optional[float]: + def total(self) -> float | None: """Return total in kWh.""" try: return self["total"] except ValueError: return None - def __repr__(self): + def __repr__(self) -> str: return ( f"" ) - def __getitem__(self, item): + def __getitem__(self, item: str) -> float | None: """Return value in wanted units.""" valid_keys = [ "voltage_mv", @@ -79,8 +81,11 @@ class EmeterStatus(dict): return super().__getitem__(item[: item.find("_")]) * 1000 else: # downscale for i in super().keys(): # noqa: SIM118 - if i.startswith(item): - return self.__getitem__(i) / 1000 + if ( + i.startswith(item) + and (value := self.__getitem__(i)) is not None + ): + return value / 1000 - _LOGGER.debug(f"Unable to find value for '{item}'") + _LOGGER.debug("Unable to find value for '%s'", item) return None diff --git a/kasa/exceptions.py b/kasa/exceptions.py index 99b7974d..a0ecbf8f 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -1,39 +1,76 @@ """python-kasa exceptions.""" + +from __future__ import annotations + +from asyncio import TimeoutError as _asyncioTimeoutError from enum import IntEnum -from typing import Optional +from functools import cache +from typing import Any -class SmartDeviceException(Exception): - """Base exception for device errors.""" +class KasaException(Exception): + """Base exception for library errors.""" - def __init__(self, *args, **kwargs): - self.error_code: Optional["SmartErrorCode"] = kwargs.get("error_code", None) + +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 UnsupportedDeviceException(SmartDeviceException): - """Exception for trying to connect to unsupported devices.""" +class DeviceError(KasaException): + """Base exception for device errors.""" - def __init__(self, *args, **kwargs): - self.discovery_result = kwargs.get("discovery_result") - super().__init__(*args, **kwargs) + 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 AuthenticationException(SmartDeviceException): +class AuthenticationError(DeviceError): """Base exception for device authentication errors.""" -class RetryableException(SmartDeviceException): +class _RetryableError(DeviceError): """Retryable exception for device errors.""" -class TimeoutException(SmartDeviceException): - """Timeout 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 @@ -42,6 +79,8 @@ class SmartErrorCode(IntEnum): 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 @@ -88,11 +127,66 @@ class SmartErrorCode(IntEnum): DST_ERROR = -2301 DST_SAVE_ERROR = -2302 + SYSTEM_ERROR = -40101 + INVALID_ARGUMENTS = -40209 + + # Camera error codes + SESSION_EXPIRED = -40401 + 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 = [ @@ -100,8 +194,6 @@ SMART_AUTHENTICATION_ERRORS = [ SmartErrorCode.LOGIN_FAILED_ERROR, SmartErrorCode.AES_DECODE_FAIL_ERROR, SmartErrorCode.HAND_SHAKE_FAILED_ERROR, -] - -SMART_TIMEOUT_ERRORS = [ - SmartErrorCode.SESSION_TIMEOUT_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 00000000..ff19baf9 --- /dev/null +++ b/kasa/feature.py @@ -0,0 +1,305 @@ +"""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): +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 +Device time (device_time): 2024-02-23 02:40:15+01:00 + +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 + +_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: Any = 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) or value 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 else choice for choice in choices] + ) + if self.precision_hint is not None and isinstance(value, float): + value = round(value, self.precision_hint) + + s = f"{self.name} ({self.id}): {value}" + if self.unit is not None: + s += f" {self.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 00000000..87e3626a --- /dev/null +++ b/kasa/httpclient.py @@ -0,0 +1,164 @@ +"""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: + if resp.status == 200: + response_data = await resp.read() + if return_json: + response_data = json_loads(response_data.decode()) + + 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 00000000..e5fd4cae --- /dev/null +++ b/kasa/interfaces/__init__.py @@ -0,0 +1,23 @@ +"""Package for interfaces.""" + +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__ = [ + "Fan", + "Energy", + "Led", + "Light", + "LightEffect", + "LightState", + "LightPreset", + "Thermostat", + "ThermostatState", + "Time", +] diff --git a/kasa/interfaces/energy.py b/kasa/interfaces/energy.py new file mode 100644 index 00000000..c57a3ed8 --- /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: 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 00000000..9462ad88 --- /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 00000000..2d34597b --- /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 00000000..89058f98 --- /dev/null +++ b/kasa/interfaces/light.py @@ -0,0 +1,199 @@ +"""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 Annotated, NamedTuple + +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 is_dimmable(self) -> bool: + """Whether the light supports brightness changes.""" + + @property + @abstractmethod + def is_color(self) -> bool: + """Whether the bulb supports color changes.""" + + @property + @abstractmethod + def is_variable_color_temp(self) -> bool: + """Whether the bulb supports color temperature changes.""" + + @property + @abstractmethod + def valid_temperature_range(self) -> ColorTempRange: + """Return the device-specific white temperature range (in Kelvin). + + :return: White temperature range in Kelvin (minimum, maximum) + """ + + @property + @abstractmethod + def has_effects(self) -> bool: + """Return True if the device supports effects.""" + + @property + @abstractmethod + def hsv(self) -> Annotated[HSV, FeatureAttribute()]: + """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.""" diff --git a/kasa/interfaces/lighteffect.py b/kasa/interfaces/lighteffect.py new file mode 100644 index 00000000..fa50dd3e --- /dev/null +++ b/kasa/interfaces/lighteffect.py @@ -0,0 +1,120 @@ +"""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" + + 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 state or 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 00000000..586671e7 --- /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 00000000..de7831b0 --- /dev/null +++ b/kasa/interfaces/thermostat.py @@ -0,0 +1,65 @@ +"""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" + Off = "off" + 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 00000000..2659b3b3 --- /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 00000000..3b5b01c6 --- /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/effects.py b/kasa/iot/effects.py similarity index 98% rename from kasa/effects.py rename to kasa/iot/effects.py index cf72bb8d..8b3e7b32 100644 --- a/kasa/effects.py +++ b/kasa/iot/effects.py @@ -1,6 +1,8 @@ """Module for light strip effects (LB*, KL*, KB*).""" -from typing import List, cast +from __future__ import annotations + +from typing import cast EFFECT_AURORA = { "custom": 0, @@ -292,5 +294,5 @@ EFFECTS_LIST_V1 = [ EFFECT_VALENTINES, ] -EFFECT_NAMES_V1: List[str] = [cast(str, effect["name"]) for effect in EFFECTS_LIST_V1] +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/smartbulb.py b/kasa/iot/iotbulb.py similarity index 62% rename from kasa/smartbulb.py rename to kasa/iot/iotbulb.py index 8897cece..cb2e858c 100644 --- a/kasa/smartbulb.py +++ b/kasa/iot/iotbulb.py @@ -1,50 +1,34 @@ """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 Any, Dict, List, NamedTuple, Optional, cast +from typing import Annotated, cast -try: - from pydantic.v1 import BaseModel, Field, root_validator -except ImportError: - from pydantic import BaseModel, Field, root_validator +from mashumaro import DataClassDictMixin +from mashumaro.config import BaseConfig +from mashumaro.types import Alias -from .deviceconfig import DeviceConfig -from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage -from .protocol import TPLinkProtocol -from .smartdevice import DeviceType, SmartDevice, SmartDeviceException, requires_update - - -class ColorTempRange(NamedTuple): - """Color temperature range.""" - - min: int - max: int - - -class HSV(NamedTuple): - """Hue-saturation-value.""" - - hue: int - saturation: int - value: int - - -class SmartBulbPreset(BaseModel): - """Bulb configuration preset.""" - - index: int - brightness: int - - # These are not available for effect mode presets on light strips - hue: Optional[int] - saturation: Optional[int] - color_temp: Optional[int] - - # Variables for effect mode presets - custom: Optional[int] - id: Optional[str] - mode: Optional[int] +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): @@ -54,9 +38,12 @@ class BehaviorMode(str, Enum): Last = "last_status" #: Use chosen preset. Preset = "customize_preset" + #: Circadian + Circadian = "circadian" -class TurnOnBehavior(BaseModel): +@dataclass +class TurnOnBehavior(DataClassDictMixin): """Model to present a single turn on behavior. :param int preset: the index number of wanted preset. @@ -67,34 +54,30 @@ class TurnOnBehavior(BaseModel): to contain either the preset index, or ``None`` for the last known state. """ - #: Index of preset to use, or ``None`` for the last known state. - preset: Optional[int] = Field(alias="index", default=None) + class Config(BaseConfig): + """Serialization config.""" + + omit_none = True + serialize_by_alias = True + #: Wanted behavior mode: BehaviorMode - - @root_validator - def _mode_based_on_preset(cls, values): - """Set the mode based on the preset value.""" - if values["preset"] is not None: - values["mode"] = BehaviorMode.Preset - else: - values["mode"] = BehaviorMode.Last - - return values - - class Config: - """Configuration to make the validator run when changing the values.""" - - validate_assignment = True + #: 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 -class TurnOnBehaviors(BaseModel): +@dataclass +class TurnOnBehaviors(DataClassDictMixin): """Model to contain turn on behaviors.""" #: The behavior when the bulb is turned on programmatically. - soft: TurnOnBehavior = Field(alias="soft_on") + soft: Annotated[TurnOnBehavior, Alias("soft_on")] #: The behavior when the bulb has been off from mains power. - hard: TurnOnBehavior = Field(alias="hard_on") + hard: Annotated[TurnOnBehavior, Alias("hard_on")] TPLINK_KELVIN = { @@ -104,7 +87,7 @@ TPLINK_KELVIN = { "KB130": ColorTempRange(2500, 9000), "KL130": ColorTempRange(2500, 9000), "KL125": ColorTempRange(2500, 6500), - "KL135": ColorTempRange(2500, 6500), + "KL135": ColorTempRange(2500, 9000), r"KL120\(EU\)": ColorTempRange(2700, 6500), r"KL120\(US\)": ColorTempRange(2700, 5000), r"KL430": ColorTempRange(2500, 9000), @@ -116,7 +99,7 @@ NON_COLOR_MODE_FLAGS = {"transition_period", "on_off"} _LOGGER = logging.getLogger(__name__) -class SmartBulb(SmartDevice): +class IotBulb(IotDevice): r"""Representation of a TP-Link Smart Bulb. To initialize, you have to await :func:`update()` at least once. @@ -127,12 +110,12 @@ class SmartBulb(SmartDevice): so you must await :func:`update()` to fetch updates values from the device. Errors reported by the device are raised as - :class:`SmartDeviceExceptions `, + :class:`KasaException `, and should be handled by the user of the library. Examples: >>> import asyncio - >>> bulb = SmartBulb("127.0.0.1") + >>> bulb = IotBulb("127.0.0.1") >>> asyncio.run(bulb.update()) >>> print(bulb.alias) Bulb2 @@ -197,10 +180,10 @@ class SmartBulb(SmartDevice): Bulb configuration presets can be accessed using the :func:`presets` property: - >>> bulb.presets - [SmartBulbPreset(index=0, brightness=50, hue=0, saturation=0, color_temp=2700, custom=None, id=None, mode=None), SmartBulbPreset(index=1, brightness=100, hue=0, saturation=75, color_temp=0, custom=None, id=None, mode=None), SmartBulbPreset(index=2, brightness=100, hue=120, saturation=75, color_temp=0, custom=None, id=None, mode=None), SmartBulbPreset(index=3, brightness=100, hue=240, saturation=75, color_temp=0, custom=None, id=None, mode=None)] + >>> [ 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.smartbulb.SmartBulbPreset` + To modify an existing preset, pass :class:`~kasa.interfaces.light.LightPreset` instance to :func:`save_preset` method: >>> preset = bulb.presets[0] @@ -208,6 +191,7 @@ class SmartBulb(SmartDevice): 50 >>> preset.brightness = 100 >>> asyncio.run(bulb.save_preset(preset)) + >>> asyncio.run(bulb.update()) >>> bulb.presets[0].brightness 100 @@ -221,49 +205,59 @@ class SmartBulb(SmartDevice): self, host: str, *, - config: Optional[DeviceConfig] = None, - protocol: Optional[TPLinkProtocol] = None, + config: DeviceConfig | None = None, + protocol: BaseProtocol | None = None, ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Bulb - self.add_module("schedule", Schedule(self, "smartlife.iot.common.schedule")) - self.add_module("usage", Usage(self, "smartlife.iot.common.schedule")) - self.add_module("antitheft", Antitheft(self, "smartlife.iot.common.anti_theft")) - self.add_module("time", Time(self, "smartlife.iot.common.timesetting")) - self.add_module("emeter", Emeter(self, self.emeter_type)) - self.add_module("countdown", Countdown(self, "countdown")) - self.add_module("cloud", Cloud(self, "smartlife.iot.common.cloud")) + + 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: + 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: + 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: + 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: + 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 SmartDeviceException("Color temperature not supported") + 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 @@ -275,11 +269,11 @@ class SmartBulb(SmartDevice): @property # type: ignore @requires_update - def light_state(self) -> Dict[str, str]: + def light_state(self) -> dict[str, str]: """Query the light state.""" light_state = self.sys_info["light_state"] if light_state is None: - raise SmartDeviceException( + raise KasaException( "The device has no light_state or you have not called update()" ) @@ -294,11 +288,11 @@ class SmartBulb(SmartDevice): @property # type: ignore @requires_update - def has_effects(self) -> bool: + 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]: + async def get_light_details(self) -> dict[str, int]: """Return light details. Example:: @@ -311,32 +305,36 @@ class SmartBulb(SmartDevice): async def get_turn_on_behavior(self) -> TurnOnBehaviors: """Return the behavior for turning the bulb on.""" - return TurnOnBehaviors.parse_obj( + return TurnOnBehaviors.from_dict( await self._query_helper(self.LIGHT_SERVICE, "get_default_behavior") ) - async def set_turn_on_behavior(self, behavior: TurnOnBehaviors): + 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.dict(by_alias=True) + self.LIGHT_SERVICE, "set_default_behavior", behavior.to_dict() ) - async def get_light_state(self) -> Dict[str, 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: Optional[int] = None - ) -> Dict: + 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 @@ -357,35 +355,33 @@ class SmartBulb(SmartDevice): @property # type: ignore @requires_update - def hsv(self) -> HSV: + def _hsv(self) -> HSV: """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.") + 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 = light_state["brightness"] + value = self._brightness - return HSV(hue, saturation, value) - - def _raise_for_invalid_brightness(self, value): - if not isinstance(value, int) or not (0 <= value <= 100): - raise ValueError(f"Invalid brightness value: {value} (valid range: 0-100%)") + # 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( + async def _set_hsv( self, hue: int, saturation: int, - value: Optional[int] = None, + value: int | None = None, *, - transition: Optional[int] = None, - ) -> Dict: + transition: int | None = None, + ) -> dict: """Set new HSV. :param int hue: hue in degrees @@ -393,13 +389,17 @@ class SmartBulb(SmartDevice): :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 self._is_color: + raise KasaException("Bulb does not support color.") - if not isinstance(hue, int) or not (0 <= hue <= 360): + 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) or not (0 <= saturation <= 100): + 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%)" ) @@ -414,31 +414,31 @@ class SmartBulb(SmartDevice): self._raise_for_invalid_brightness(value) light_state["brightness"] = value - return await self.set_light_state(light_state, transition=transition) + return await self._set_light_state(light_state, transition=transition) @property # type: ignore @requires_update - def color_temp(self) -> int: + 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.") + 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=None, transition: Optional[int] = None - ) -> Dict: + 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 SmartDeviceException("Bulb does not support colortemp.") + if not self._is_variable_color_temp: + raise KasaException("Bulb does not support colortemp.") - valid_temperature_range = self.valid_temperature_range + 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( @@ -450,51 +450,47 @@ class SmartBulb(SmartDevice): if brightness is not None: light_state["brightness"] = brightness - return await self.set_light_state(light_state, transition=transition) + 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: + def _brightness(self) -> int: """Return the current brightness in percentage.""" - if not self.is_dimmable: # pragma: no cover - raise SmartDeviceException("Bulb is not dimmable.") + 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: Optional[int] = None - ) -> Dict: + 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 SmartDeviceException("Bulb is not dimmable.") + 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 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 - info["Presets"] = self.presets - - return info + return await self._set_light_state(light_state, transition=transition) @property # type: ignore @requires_update @@ -503,19 +499,19 @@ class SmartBulb(SmartDevice): light_state = self.light_state return bool(light_state["on_off"]) - async def turn_off(self, *, transition: Optional[int] = None, **kwargs) -> Dict: + 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) + return await self._set_light_state({"on_off": 0}, transition=transition) - async def turn_on(self, *, transition: Optional[int] = None, **kwargs) -> Dict: + 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) + return await self._set_light_state({"on_off": 1}, transition=transition) @property # type: ignore @requires_update @@ -523,7 +519,7 @@ class SmartBulb(SmartDevice): """Return that the bulb has an emeter.""" return True - async def set_alias(self, alias: str) -> None: + async def set_alias(self, alias: str) -> dict: """Set the device name (alias). Overridden to use a different module name. @@ -532,28 +528,6 @@ class SmartBulb(SmartDevice): "smartlife.iot.common.system", "set_dev_alias", {"alias": alias} ) - @property # type: ignore - @requires_update - def presets(self) -> List[SmartBulbPreset]: - """Return a list of available bulb setting presets.""" - return [SmartBulbPreset(**vals) for vals in self.sys_info["preferred_state"]] - - async def save_preset(self, preset: SmartBulbPreset): - """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 SmartDeviceException("Device does not supported saving presets") - - if preset.index >= len(self.presets): - raise SmartDeviceException("Invalid preset index") - - return await self._query_helper( - self.LIGHT_SERVICE, "set_preferred_state", preset.dict(exclude_none=True) - ) - @property def max_device_response_size(self) -> int: """Returns the maximum response size the device can safely construct.""" diff --git a/kasa/iot/iotcamera.py b/kasa/iot/iotcamera.py new file mode 100644 index 00000000..8965948c --- /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 00000000..851f21cc --- /dev/null +++ b/kasa/iot/iotdevice.py @@ -0,0 +1,777 @@ +"""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 + +_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.""" + 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) + + 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"] + firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1) + 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/smartdimmer.py b/kasa/iot/iotdimmer.py similarity index 66% rename from kasa/smartdimmer.py rename to kasa/iot/iotdimmer.py index ca0960f1..3960e641 100644 --- a/kasa/smartdimmer.py +++ b/kasa/iot/iotdimmer.py @@ -1,12 +1,17 @@ """Module for dimmers (currently only HS220).""" -from enum import Enum -from typing import Any, Dict, Optional -from kasa.deviceconfig import DeviceConfig -from kasa.modules import AmbientLight, Motion -from kasa.protocol import TPLinkProtocol -from kasa.smartdevice import DeviceType, SmartDeviceException, requires_update -from kasa.smartplug import SmartPlug +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, Light, Motion class ButtonAction(Enum): @@ -32,7 +37,7 @@ class FadeType(Enum): FadeOff = "fade_off" -class SmartDimmer(SmartPlug): +class IotDimmer(IotPlug): r"""Representation of a TP-Link Smart Dimmer. Dimmers work similarly to plugs, but provide also support for @@ -45,12 +50,12 @@ class SmartDimmer(SmartPlug): 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, + 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 = SmartDimmer("192.168.1.105") + >>> dimmer = IotDimmer("192.168.1.105") >>> asyncio.run(dimmer.turn_on()) >>> dimmer.brightness 25 @@ -69,40 +74,45 @@ class SmartDimmer(SmartPlug): self, host: str, *, - config: Optional[DeviceConfig] = None, - protocol: Optional[TPLinkProtocol] = None, + 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("motion", Motion(self, "smartlife.iot.PIR")) - self.add_module("ambient", AmbientLight(self, "smartlife.iot.LAS")) + self.add_module(Module.IotMotion, Motion(self, "smartlife.iot.PIR")) + self.add_module(Module.IotAmbientLight, AmbientLight(self, "smartlife.iot.LAS")) + self.add_module(Module.Light, Light(self, "light")) @property # type: ignore @requires_update - def brightness(self) -> int: + 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.") + 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: Optional[int] = None - ): + 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 SmartDeviceException("Device is not dimmable.") + if not self._is_dimmable: + raise KasaException("Device is not dimmable.") if not isinstance(brightness, int): raise ValueError( @@ -110,7 +120,9 @@ class SmartDimmer(SmartPlug): ) if not 0 <= brightness <= 100: - raise ValueError("Brightness value %s is not valid." % brightness) + 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. @@ -124,7 +136,7 @@ class SmartDimmer(SmartPlug): self.DIMMER_SERVICE, "set_brightness", {"brightness": brightness} ) - async def turn_off(self, *, transition: Optional[int] = None, **kwargs): + async def turn_off(self, *, transition: int | None = None, **kwargs) -> dict: """Turn the bulb off. :param int transition: transition duration in milliseconds. @@ -135,37 +147,38 @@ class SmartDimmer(SmartPlug): return await super().turn_off() @requires_update - async def turn_on(self, *, transition: Optional[int] = None, **kwargs): + 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 + brightness=self._brightness, transition=transition ) return await super().turn_on() - async def set_dimmer_transition(self, brightness: int, transition: int): + 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 ValueError( - "Brightness must be integer, " "not of %s.", type(brightness) - ) + raise TypeError(f"Brightness must be an integer, not {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) + 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("Transition value %s is not valid." % transition) + raise ValueError(f"Transition value {transition} is not valid.") return await self._query_helper( self.DIMMER_SERVICE, @@ -174,7 +187,7 @@ class SmartDimmer(SmartPlug): ) @requires_update - async def get_behaviors(self): + async def get_behaviors(self) -> dict: """Return button behavior settings.""" behaviors = await self._query_helper( self.DIMMER_SERVICE, "get_default_behavior", {} @@ -183,8 +196,8 @@ class SmartDimmer(SmartPlug): @requires_update async def set_button_action( - self, action_type: ActionType, action: ButtonAction, index: Optional[int] = None - ): + 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. @@ -194,32 +207,35 @@ class SmartDimmer(SmartPlug): """ action_type_setter = f"set_{action_type}" - payload: Dict[str, Any] = {"mode": str(action)} + payload: dict[str, Any] = {"mode": str(action)} if index is not None: payload["index"] = index - await self._query_helper(self.DIMMER_SERVICE, action_type_setter, payload) + 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): + 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} - await self._query_helper(self.DIMMER_SERVICE, fade_type_setter, payload) + return await self._query_helper(self.DIMMER_SERVICE, fade_type_setter, payload) @property # type: ignore @requires_update - def is_dimmable(self) -> bool: + 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 + @property + def _is_variable_color_temp(self) -> bool: + """Whether the device supports variable color temp.""" + return False - return info + @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 00000000..f4107b3c --- /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 00000000..115e9e82 --- /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 00000000..288d5376 --- /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 00000000..9372c8fa --- /dev/null +++ b/kasa/iot/iotstrip.py @@ -0,0 +1,489 @@ +"""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 + +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, 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")) + 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: + await plug.turn_on() + return {} + + async def turn_off(self, **kwargs) -> dict: + """Turn the strip off.""" + for plug in self.children: + 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")) + + 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 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 00000000..65538341 --- /dev/null +++ b/kasa/iot/iottimezone.py @@ -0,0 +1,185 @@ +"""Module for io device timezone lookups.""" + +from __future__ import annotations + +import logging +from datetime import datetime, timedelta, tzinfo +from typing import cast +from zoneinfo import ZoneInfo + +from ..cachedzoneinfo import CachedZoneInfo + +_LOGGER = logging.getLogger(__name__) + + +async def get_timezone(index: int) -> tzinfo: + """Get the timezone from the index.""" + if 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 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): + if _is_same_timezone(tzone, await get_timezone(i)): + return i + raise ValueError("Device does not support timezone %s", name) + + +async def get_matching_timezones(tzone: tzinfo) -> list[str]: + """Return the iot firmware index for a valid IANA timezone key.""" + matches = [] + 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): + fw_tz = await get_timezone(i) + 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 utcffset and dst offset. + + Iot devices only support a limited static list of IANA timezones; this is used to + check if a static timezone matches the same utc offset and dst settings. + """ + 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 + + +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/modules/__init__.py b/kasa/iot/modules/__init__.py similarity index 70% rename from kasa/modules/__init__.py rename to kasa/iot/modules/__init__.py index 8ad5088d..6fd63a70 100644 --- a/kasa/modules/__init__.py +++ b/kasa/iot/modules/__init__.py @@ -1,10 +1,14 @@ """Module for individual feature modules.""" + from .ambientlight import AmbientLight from .antitheft import Antitheft from .cloud import Cloud from .countdown import Countdown from .emeter import Emeter -from .module import Module +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 @@ -17,7 +21,11 @@ __all__ = [ "Cloud", "Countdown", "Emeter", - "Module", + "Led", + "Light", + "LightEffect", + "LightPreset", + "IotLightPreset", "Motion", "Rule", "RuleModule", diff --git a/kasa/iot/modules/ambientlight.py b/kasa/iot/modules/ambientlight.py new file mode 100644 index 00000000..ac5c3488 --- /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/modules/antitheft.py b/kasa/iot/modules/antitheft.py similarity index 99% rename from kasa/modules/antitheft.py rename to kasa/iot/modules/antitheft.py index c885a70c..07d94b9d 100644 --- a/kasa/modules/antitheft.py +++ b/kasa/iot/modules/antitheft.py @@ -1,4 +1,5 @@ """Implementation of the antitheft module.""" + from .rulemodule import RuleModule diff --git a/kasa/iot/modules/cloud.py b/kasa/iot/modules/cloud.py new file mode 100644 index 00000000..d4e91a07 --- /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/modules/countdown.py b/kasa/iot/modules/countdown.py similarity index 99% rename from kasa/modules/countdown.py rename to kasa/iot/modules/countdown.py index 9f3e59c1..d1d5c23e 100644 --- a/kasa/modules/countdown.py +++ b/kasa/iot/modules/countdown.py @@ -1,4 +1,5 @@ """Implementation for the countdown timer.""" + from .rulemodule import RuleModule diff --git a/kasa/modules/emeter.py b/kasa/iot/modules/emeter.py similarity index 58% rename from kasa/modules/emeter.py rename to kasa/iot/modules/emeter.py index 11eed48f..012bda04 100644 --- a/kasa/modules/emeter.py +++ b/kasa/iot/modules/emeter.py @@ -1,47 +1,89 @@ """Implementation of the emeter module.""" -from datetime import datetime -from typing import Dict, List, Optional, Union -from ..emeterstatus import EmeterStatus +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): +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 realtime(self) -> EmeterStatus: + def status(self) -> EmeterStatus: """Return current energy readings.""" return EmeterStatus(self.data["get_realtime"]) @property - def emeter_today(self) -> Optional[float]: + 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) + return data.get(today, 0.0) @property - def emeter_this_month(self) -> Optional[float]: + 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) + return data.get(current_month, 0.0) - async def erase_stats(self): + @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_realtime(self): + async def get_status(self) -> EmeterStatus: """Return real-time statistics.""" - return await self.call("get_realtime") + return EmeterStatus(await self.call("get_realtime")) - async def get_daystat(self, *, year=None, month=None, kwh=True) -> Dict: + 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, ...}. @@ -50,7 +92,9 @@ class Emeter(Usage): data = self._convert_stat_data(data["day_list"], entry_key="day", kwh=kwh) return data - async def get_monthstat(self, *, year=None, kwh=True) -> Dict: + 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, ...}. @@ -61,11 +105,11 @@ class Emeter(Usage): def _convert_stat_data( self, - data: List[Dict[str, Union[int, float]]], + data: list[dict[str, int | float]], entry_key: str, kwh: bool = True, - key: Optional[int] = None, - ) -> Dict[Union[int, float], Union[int, float]]: + 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:: diff --git a/kasa/iot/modules/led.py b/kasa/iot/modules/led.py new file mode 100644 index 00000000..8a5727b0 --- /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 00000000..5f5c34b9 --- /dev/null +++ b/kasa/iot/modules/light.py @@ -0,0 +1,268 @@ +"""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, ColorTempRange, 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: + 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="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 is_dimmable(self) -> int: + """Whether the bulb supports brightness changes.""" + return self._device._is_dimmable + + @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 is_color(self) -> bool: + """Whether the light supports color changes.""" + if (bulb := self._get_bulb_device()) is None: + return False + return bulb._is_color + + @property + def is_variable_color_temp(self) -> bool: + """Whether the bulb supports color temperature changes.""" + if (bulb := self._get_bulb_device()) is None: + return False + return bulb._is_variable_color_temp + + @property + def has_effects(self) -> bool: + """Return True if the device supports effects.""" + if (bulb := self._get_bulb_device()) is None: + return False + return bulb._has_effects + + @property + def hsv(self) -> 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 valid_temperature_range(self) -> ColorTempRange: + """Return the device-specific white temperature range (in Kelvin). + + :return: White temperature range in Kelvin (minimum, maximum) + """ + if ( + bulb := self._get_bulb_device() + ) is None or not bulb._is_variable_color_temp: + raise KasaException("Light does not support colortemp.") + return bulb._valid_temperature_range + + @property + def color_temp(self) -> 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 00000000..cdfaaae1 --- /dev/null +++ b/kasa/iot/modules/lighteffect.py @@ -0,0 +1,135 @@ +"""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 state. + + Example: + {'brightness': 50, + 'custom': 0, + 'enable': 0, + 'id': '', + 'name': ''} + """ + eff = self.data["lighting_effect_state"] + name = eff["name"] + if eff["enable"]: + return name + + 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 00000000..76d39860 --- /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 00000000..e65cbd93 --- /dev/null +++ b/kasa/iot/modules/motion.py @@ -0,0 +1,102 @@ +"""Implementation of the motion detection (PIR) module found in some dimmers.""" + +from __future__ import annotations + +import logging +from enum import Enum + +from ...exceptions import KasaException +from ...feature import Feature +from ..iotmodule import IotModule + +_LOGGER = logging.getLogger(__name__) + + +class Range(Enum): + """Range for motion detection.""" + + Far = 0 + Mid = 1 + Near = 2 + Custom = 3 + + +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 + + 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, + ) + ) + + def query(self) -> dict: + """Request PIR configuration.""" + return self.query_for_command("get_config") + + @property + def config(self) -> dict: + """Return current configuration.""" + return self.data["get_config"] + + @property + def range(self) -> Range: + """Return motion detection range.""" + return Range(self.config["trigger_index"]) + + @property + def enabled(self) -> bool: + """Return True if module is enabled.""" + return bool(self.config["enable"]) + + async def set_enabled(self, state: bool) -> dict: + """Enable/disable PIR.""" + return await self.call("set_enable", {"enable": int(state)}) + + async def set_range( + self, *, range: Range | None = None, custom_range: int | None = None + ) -> dict: + """Set the range for the sensor. + + :param range: for using standard ranges + :param custom_range: range in decimeters, overrides the range parameter + """ + if custom_range is not None: + payload = {"index": Range.Custom.value, "value": custom_range} + elif range is not None: + payload = {"index": range.value} + else: + raise KasaException("Either range or custom_range need to be defined") + + 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}) diff --git a/kasa/modules/rulemodule.py b/kasa/iot/modules/rulemodule.py similarity index 59% rename from kasa/modules/rulemodule.py rename to kasa/iot/modules/rulemodule.py index 05ef500f..ba08b366 100644 --- a/kasa/modules/rulemodule.py +++ b/kasa/iot/modules/rulemodule.py @@ -1,15 +1,14 @@ """Base implementation for all rule-based modules.""" + +from __future__ import annotations + import logging +from dataclasses import dataclass from enum import Enum -from typing import Dict, List, Optional -try: - from pydantic.v1 import BaseModel -except ImportError: - from pydantic import BaseModel +from mashumaro import DataClassDictMixin - -from .module import Module, merge +from ..iotmodule import IotModule, merge class Action(Enum): @@ -30,58 +29,59 @@ class TimeOption(Enum): AtSunset = 2 -class Rule(BaseModel): +@dataclass +class Rule(DataClassDictMixin): """Representation of a rule.""" id: str name: str - enable: bool - wday: List[int] - repeat: bool + enable: int + wday: list[int] + repeat: int # start action - sact: Optional[Action] - stime_opt: TimeOption - smin: int + sact: Action | None = None + stime_opt: TimeOption | None = None + smin: int | None = None - eact: Optional[Action] - etime_opt: TimeOption - emin: int + eact: Action | None = None + etime_opt: TimeOption | None = None + emin: int | None = None # Only on bulbs - s_light: Optional[Dict] + s_light: dict | None = None _LOGGER = logging.getLogger(__name__) -class RuleModule(Module): +class RuleModule(IotModule): """Base class for rule-based modules, such as countdown and antitheft.""" - def query(self): + 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]: + def rules(self) -> list[Rule]: """Return the list of rules for the service.""" try: return [ - Rule.parse_obj(rule) for rule in self.data["get_rules"]["rule_list"] + 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): + async def set_enabled(self, state: bool) -> dict: """Enable or disable the service.""" - return await self.call("set_overall_enable", state) + return await self.call("set_overall_enable", {"enable": state}) - async def delete_rule(self, rule: Rule): + 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): + async def delete_all_rules(self) -> dict: """Delete all rules.""" return await self.call("delete_all_rules") diff --git a/kasa/modules/schedule.py b/kasa/iot/modules/schedule.py similarity index 99% rename from kasa/modules/schedule.py rename to kasa/iot/modules/schedule.py index 62371692..fe881951 100644 --- a/kasa/modules/schedule.py +++ b/kasa/iot/modules/schedule.py @@ -1,4 +1,5 @@ """Schedule module implementation.""" + from .rulemodule import RuleModule diff --git a/kasa/iot/modules/time.py b/kasa/iot/modules/time.py new file mode 100644 index 00000000..896172de --- /dev/null +++ b/kasa/iot/modules/time.py @@ -0,0 +1,93 @@ +"""Provides the current time and timezone information.""" + +from __future__ import annotations + +from datetime import UTC, datetime, tzinfo + +from ...exceptions import KasaException +from ...interfaces import Time as TimeInterface +from ..iotmodule import IotModule, merge +from ..iottimezone import 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 res := self.data.get("get_timezone"): + self._timezone = await get_timezone(res.get("index")) + + @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/modules/usage.py b/kasa/iot/modules/usage.py similarity index 77% rename from kasa/modules/usage.py rename to kasa/iot/modules/usage.py index 10b9689d..89d8cca2 100644 --- a/kasa/modules/usage.py +++ b/kasa/iot/modules/usage.py @@ -1,14 +1,16 @@ """Implementation of the usage interface.""" + +from __future__ import annotations + from datetime import datetime -from typing import Dict -from .module import Module, merge +from ..iotmodule import IotModule, merge -class Usage(Module): +class Usage(IotModule): """Baseclass for emeter/usage interfaces.""" - def query(self): + def query(self) -> dict: """Return the base query.""" now = datetime.now() year = now.year @@ -23,22 +25,22 @@ class Usage(Module): return req @property - def estimated_query_response_size(self): + def estimated_query_response_size(self) -> int: """Estimated maximum query response size.""" return 2048 @property - def daily_data(self): + def daily_data(self) -> list[dict]: """Return statistics on daily basis.""" return self.data["get_daystat"]["day_list"] @property - def monthly_data(self): + def monthly_data(self) -> list[dict]: """Return statistics on monthly basis.""" return self.data["get_monthstat"]["month_list"] @property - def usage_today(self): + 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. @@ -48,7 +50,7 @@ class Usage(Module): return None @property - def usage_this_month(self): + 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. @@ -57,7 +59,9 @@ class Usage(Module): return entry["time"] return None - async def get_raw_daystat(self, *, year=None, month=None) -> Dict: + 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 @@ -66,14 +70,16 @@ class Usage(Module): return await self.call("get_daystat", {"year": year, "month": month}) - async def get_raw_monthstat(self, *, year=None) -> Dict: + 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=None, month=None) -> Dict: + 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, ...}. @@ -82,7 +88,7 @@ class Usage(Module): data = self._convert_stat_data(data["day_list"], entry_key="day") return data - async def get_monthstat(self, *, year=None) -> Dict: + 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, ...}. @@ -91,11 +97,11 @@ class Usage(Module): data = self._convert_stat_data(data["month_list"], entry_key="month") return data - async def erase_stats(self): + async def erase_stats(self) -> dict: """Erase all stats.""" return await self.call("erase_runtime_stat") - def _convert_stat_data(self, data, entry_key) -> Dict: + 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:: @@ -111,6 +117,6 @@ class Usage(Module): if not data: return {} - data = {entry[entry_key]: entry["time"] for entry in data} + res = {entry[entry_key]: entry["time"] for entry in data} - return data + return res diff --git a/kasa/iotprotocol.py b/kasa/iotprotocol.py deleted file mode 100755 index 470f4055..00000000 --- a/kasa/iotprotocol.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Module for the IOT legacy IOT KASA protocol.""" -import asyncio -import logging -from typing import Dict, Union - -import httpx - -from .exceptions import AuthenticationException, SmartDeviceException -from .json import dumps as json_dumps -from .protocol import BaseTransport, TPLinkProtocol - -_LOGGER = logging.getLogger(__name__) - - -class IotProtocol(TPLinkProtocol): - """Class for the legacy TPLink IOT KASA Protocol.""" - - def __init__( - self, - *, - transport: BaseTransport, - ) -> None: - """Create a protocol object.""" - super().__init__(transport=transport) - - self._query_lock = asyncio.Lock() - - async def query(self, request: Union[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 httpx.ConnectError as sdex: - if retry >= retry_count: - await self.close() - _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) - raise SmartDeviceException( - f"Unable to connect to the device: {self._host}: {sdex}" - ) from sdex - continue - except TimeoutError as tex: - await self.close() - raise SmartDeviceException( - f"Unable to connect to the device, timed out: {self._host}: {tex}" - ) from tex - except AuthenticationException as auex: - await self.close() - _LOGGER.debug( - "Unable to authenticate with %s, not retrying", self._host - ) - raise auex - except SmartDeviceException as ex: - _LOGGER.debug( - "Unable to connect to the device: %s, not retrying: %s", - self._host, - ex, - ) - raise ex - except Exception as ex: - if retry >= retry_count: - await self.close() - _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) - raise SmartDeviceException( - f"Unable to connect to the device: {self._host}: {ex}" - ) from ex - continue - - # make mypy happy, this should never be reached.. - raise SmartDeviceException("Query reached somehow to unreachable") - - async def _execute_query(self, request: str, retry_count: int) -> Dict: - return await self._transport.send(request) - - async def close(self) -> None: - """Close the protocol.""" - await self._transport.close() diff --git a/kasa/json.py b/kasa/json.py index 4acc865f..8a0eab7b 100755 --- a/kasa/json.py +++ b/kasa/json.py @@ -1,15 +1,40 @@ """JSON abstraction.""" +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + try: import orjson - def dumps(obj, *, default=None): + def dumps( + obj: Any, *, default: Callable | None = None, indent: bool = False + ) -> str: """Dump JSON.""" - return orjson.dumps(obj).decode() + return orjson.dumps( + obj, option=orjson.OPT_INDENT_2 if indent else None + ).decode() loads = orjson.loads except ImportError: import json - dumps = json.dumps + 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 00000000..2870b661 --- /dev/null +++ b/kasa/module.py @@ -0,0 +1,281 @@ +"""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 __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 + 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") + 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") + + # SMART only Modules + Alarm: Final[ModuleName[smart.Alarm]] = ModuleName("Alarm") + AutoOff: Final[ModuleName[smart.AutoOff]] = ModuleName("AutoOff") + BatterySensor: Final[ModuleName[smart.BatterySensor]] = ModuleName("BatterySensor") + Brightness: Final[ModuleName[smart.Brightness]] = ModuleName("Brightness") + 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" + ) + TriggerLogs: Final[ModuleName[smart.TriggerLogs]] = ModuleName("TriggerLogs") + + 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") + + def __init__(self, device: Device, module: str) -> None: + self._device = device + self._module = module + self._module_features: dict[str, Feature] = {} + + @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 _is_bound_feature(attribute: property | Callable) -> bool: + """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 True + + return False + + +@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 _is_bound_feature(attribute_callable): + raise KasaException( + f"Attribute {attribute_name} of module {module.__class__.__name__}" + " is not bound to a feature" + ) + + 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 00000000..06ba8619 --- /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 00000000..a49de389 --- /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/modules/ambientlight.py b/kasa/modules/ambientlight.py deleted file mode 100644 index 963c73a3..00000000 --- a/kasa/modules/ambientlight.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Implementation of the ambient light (LAS) module found in some dimmers.""" -from .module import Module - -# TODO create tests and use the config reply there -# [{"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}]}] - - -class AmbientLight(Module): - """Implements ambient light controls for the motion sensor.""" - - def query(self): - """Request configuration.""" - return self.query_for_command("get_config") - - @property - def presets(self) -> dict: - """Return device-defined presets for brightness setting.""" - return self.data["level_array"] - - @property - def enabled(self) -> bool: - """Return True if the module is enabled.""" - return bool(self.data["enable"]) - - async def set_enabled(self, state: bool): - """Enable/disable LAS.""" - return await self.call("set_enable", {"enable": int(state)}) - - async def current_brightness(self) -> int: - """Return current brightness. - - Return value units. - """ - return await self.call("get_current_brt") - - async def set_brightness_limit(self, value: int): - """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/modules/cloud.py b/kasa/modules/cloud.py deleted file mode 100644 index b4eface5..00000000 --- a/kasa/modules/cloud.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Cloud module implementation.""" -try: - from pydantic.v1 import BaseModel -except ImportError: - from pydantic import BaseModel - -from .module import Module - - -class CloudInfo(BaseModel): - """Container for cloud settings.""" - - binded: bool - cld_connection: int - fwDlPage: str - fwNotifyType: int - illegalType: int - server: str - stopConnect: int - tcspInfo: str - tcspStatus: int - username: str - - -class Cloud(Module): - """Module implementing support for cloud services.""" - - def query(self): - """Request cloud connectivity info.""" - return self.query_for_command("get_info") - - @property - def info(self) -> CloudInfo: - """Return information about the cloud connectivity.""" - return CloudInfo.parse_obj(self.data["get_info"]) - - def get_available_firmwares(self): - """Return list of available firmwares.""" - return self.query_for_command("get_intl_fw_list") - - def set_server(self, url: str): - """Set the update server URL.""" - return self.query_for_command("set_server_url", {"server": url}) - - def connect(self, username: str, password: str): - """Login to the cloud using given information.""" - return self.query_for_command( - "bind", {"username": username, "password": password} - ) - - def disconnect(self): - """Disconnect from the cloud.""" - return self.query_for_command("unbind") diff --git a/kasa/modules/module.py b/kasa/modules/module.py deleted file mode 100644 index 40890f29..00000000 --- a/kasa/modules/module.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Base class for all module implementations.""" -import collections -import logging -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING - -from ..exceptions import SmartDeviceException - -if TYPE_CHECKING: - from kasa import SmartDevice - - -_LOGGER = logging.getLogger(__name__) - - -# TODO: This is used for query construcing -def merge(d, u): - """Update dict recursively.""" - for k, v in u.items(): - if isinstance(v, collections.abc.Mapping): - d[k] = merge(d.get(k, {}), v) - else: - d[k] = v - return d - - -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. - """ - - def __init__(self, device: "SmartDevice", module: str): - self._device: "SmartDevice" = device - self._module = module - - @abstractmethod - def query(self): - """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 - def estimated_query_response_size(self): - """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): - """Return the module specific raw data from the last update.""" - if self._module not in self._device._last_update: - raise SmartDeviceException( - f"You need to call update() prior accessing module data" - f" for '{self._module}'" - ) - - return self._device._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 - - def call(self, method, params=None): - """Call the given method with the given parameters.""" - return self._device._query_helper(self._module, method, params) - - def query_for_command(self, query, params=None): - """Create a request object for the given parameters.""" - return self._device._create_request(self._module, query, params) - - def __repr__(self) -> str: - return ( - f"" - ) diff --git a/kasa/modules/motion.py b/kasa/modules/motion.py deleted file mode 100644 index 71d1a617..00000000 --- a/kasa/modules/motion.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Implementation of the motion detection (PIR) module found in some dimmers.""" -from enum import Enum -from typing import Optional - -from ..exceptions import SmartDeviceException -from .module import Module - - -class Range(Enum): - """Range for motion detection.""" - - Far = 0 - Mid = 1 - Near = 2 - Custom = 3 - - -# TODO: use the config reply in tests -# {"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}}} - - -class Motion(Module): - """Implements the motion detection (PIR) module.""" - - def query(self): - """Request PIR configuration.""" - return self.query_for_command("get_config") - - @property - def range(self) -> Range: - """Return motion detection range.""" - return Range(self.data["trigger_index"]) - - @property - def enabled(self) -> bool: - """Return True if module is enabled.""" - return bool(self.data["enable"]) - - async def set_enabled(self, state: bool): - """Enable/disable PIR.""" - return await self.call("set_enable", {"enable": int(state)}) - - async def set_range( - self, *, range: Optional[Range] = None, custom_range: Optional[int] = None - ): - """Set the range for the sensor. - - :param range: for using standard ranges - :param custom_range: range in decimeters, overrides the range parameter - """ - if custom_range is not None: - payload = {"index": Range.Custom.value, "value": custom_range} - elif range is not None: - payload = {"index": range.value} - else: - raise SmartDeviceException( - "Either range or custom_range need to be defined" - ) - - return await self.call("set_trigger_sens", payload) - - @property - def inactivity_timeout(self) -> int: - """Return inactivity timeout in milliseconds.""" - return self.data["cold_time"] - - async def set_inactivity_timeout(self, timeout: int): - """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}) diff --git a/kasa/modules/time.py b/kasa/modules/time.py deleted file mode 100644 index d72e2d60..00000000 --- a/kasa/modules/time.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Provides the current time and timezone information.""" -from datetime import datetime - -from ..exceptions import SmartDeviceException -from .module import Module, merge - - -class Time(Module): - """Implements the timezone settings.""" - - def query(self): - """Request time and timezone.""" - q = self.query_for_command("get_time") - - merge(q, self.query_for_command("get_timezone")) - return q - - @property - def time(self) -> datetime: - """Return current device time.""" - res = self.data["get_time"] - return datetime( - res["year"], - res["month"], - res["mday"], - res["hour"], - res["min"], - res["sec"], - ) - - @property - def timezone(self): - """Return current timezone.""" - res = self.data["get_timezone"] - return res - - async def get_time(self): - """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"], - ) - except SmartDeviceException: - return None - - async def get_timezone(self): - """Request timezone information from the device.""" - return await self.call("get_timezone") diff --git a/kasa/protocol.py b/kasa/protocol.py deleted file mode 100755 index 74023e01..00000000 --- a/kasa/protocol.py +++ /dev/null @@ -1,371 +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 contextlib -import errno -import logging -import socket -import struct -from abc import ABC, abstractmethod -from pprint import pformat as pf -from typing import Dict, Generator, Optional, Union - -# When support for cpython older than 3.11 is dropped -# async_timeout can be replaced with asyncio.timeout -from async_timeout import timeout as asyncio_timeout -from cryptography.hazmat.primitives import hashes - -from .deviceconfig import DeviceConfig -from .exceptions import SmartDeviceException -from .json import dumps as json_dumps -from .json import loads as json_loads - -_LOGGER = logging.getLogger(__name__) -_NO_RETRY_ERRORS = {errno.EHOSTDOWN, errno.EHOSTUNREACH, errno.ECONNREFUSED} -_UNSIGNED_INT_NETWORK_ORDER = struct.Struct(">I") - - -def md5(payload: bytes) -> bytes: - """Return an md5 hash of the payload.""" - digest = hashes.Hash(hashes.MD5()) # noqa: S303 - digest.update(payload) - hash = digest.finalize() - return hash - - -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 - self._timeout = config.timeout - - @property - @abstractmethod - def default_port(self) -> int: - """The default port for the transport.""" - - @property - @abstractmethod - def credentials_hash(self) -> str: - """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.""" - - -class TPLinkProtocol(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): - 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: Union[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.""" - - -class _XorTransport(BaseTransport): - """Implementation of the Xor encryption transport. - - WIP, currently only to ensure consistent __init__ method signatures - for protocol classes. Will eventually incorporate the logic from - TPLinkSmartHomeProtocol to simplify the API and re-use the IotProtocol - class. - """ - - DEFAULT_PORT: int = 9999 - - def __init__(self, *, config: DeviceConfig) -> None: - super().__init__(config=config) - - @property - def default_port(self): - """Default port for the transport.""" - return self.DEFAULT_PORT - - @property - def credentials_hash(self) -> str: - """The hashed credentials used by the transport.""" - return "" - - async def send(self, request: str) -> Dict: - """Send a message to the device and return a response.""" - return {} - - async def close(self) -> None: - """Close the transport. Abstract method to be overriden.""" - - -class TPLinkSmartHomeProtocol(TPLinkProtocol): - """Implementation of the TP-Link Smart Home protocol.""" - - INITIALIZATION_VECTOR = 171 - DEFAULT_PORT = 9999 - BLOCK_SIZE = 4 - - def __init__( - self, - *, - transport: BaseTransport, - ) -> None: - """Create a protocol object.""" - super().__init__(transport=transport) - - self.reader: Optional[asyncio.StreamReader] = None - self.writer: Optional[asyncio.StreamWriter] = None - self.query_lock = asyncio.Lock() - self.loop: Optional[asyncio.AbstractEventLoop] = None - - self._timeout = self._transport._timeout - self._port = self._transport._port - - async def query(self, 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) - assert isinstance(request, str) # noqa: S101 - - async with self.query_lock: - return await self._query(request, retry_count, self._timeout) # type: ignore[arg-type] - - 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_query(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 - debug_log = _LOGGER.isEnabledFor(logging.DEBUG) - if debug_log: - _LOGGER.debug("%s >> %s", self._host, request) - self.writer.write(TPLinkSmartHomeProtocol.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 = TPLinkSmartHomeProtocol.decrypt(buffer) - json_payload = json_loads(response) - if debug_log: - _LOGGER.debug("%s << %s", self._host, pf(json_payload)) - - 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() - - def _reset(self) -> None: - """Clear any varibles that should not survive between loops.""" - self.reader = self.writer = None - - async def _query(self, request: str, retry_count: int, timeout: int) -> Dict: - """Try to query a device.""" - # - # 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. - for retry in range(retry_count + 1): - try: - await self._connect(timeout) - except ConnectionRefusedError as ex: - await self.close() - raise SmartDeviceException( - f"Unable to connect to the device: {self._host}:{self._port}: {ex}" - ) from ex - except OSError as ex: - await self.close() - if ex.errno in _NO_RETRY_ERRORS or retry >= retry_count: - raise SmartDeviceException( - f"Unable to connect to the device:" - f" {self._host}:{self._port}: {ex}" - ) from ex - continue - except Exception as ex: - await self.close() - if retry >= retry_count: - _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) - raise SmartDeviceException( - f"Unable to connect to the device:" - f" {self._host}:{self._port}: {ex}" - ) from ex - continue - except BaseException as ex: - # Likely something cancelled the task so we need to close the connection - # as we are not in an indeterminate state - self.close_without_wait() - _LOGGER.debug( - "%s: BaseException during connect, closing connection: %s", - self._host, - ex, - ) - raise - - try: - assert self.reader is not None # noqa: S101 - assert self.writer is not None # noqa: S101 - async with asyncio_timeout(timeout): - return await self._execute_query(request) - except Exception as ex: - await self.close() - if retry >= retry_count: - _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) - raise SmartDeviceException( - f"Unable to query the device {self._host}:{self._port}: {ex}" - ) from ex - - _LOGGER.debug( - "Unable to query the device %s, retrying: %s", self._host, ex - ) - except BaseException as ex: - # Likely something cancelled the task so we need to close the connection - # as we are not in an indeterminate state - self.close_without_wait() - _LOGGER.debug( - "%s: BaseException during query, closing connection: %s", - self._host, - ex, - ) - raise - - # make mypy happy, this should never be reached.. - await self.close() - raise SmartDeviceException("Query reached somehow to unreachable") - - 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) - self._reset() - - @staticmethod - def _xor_payload(unencrypted: bytes) -> Generator[int, None, None]: - key = TPLinkSmartHomeProtocol.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( - TPLinkSmartHomeProtocol._xor_payload(plainbytes) - ) - - @staticmethod - def _xor_encrypted_payload(ciphertext: bytes) -> Generator[int, None, None]: - key = TPLinkSmartHomeProtocol.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( - TPLinkSmartHomeProtocol._xor_encrypted_payload(ciphertext) - ).decode() - - -# Try to load the kasa_crypt module and if it is available -try: - from kasa_crypt import decrypt, encrypt - - TPLinkSmartHomeProtocol.decrypt = decrypt # type: ignore[method-assign] - TPLinkSmartHomeProtocol.encrypt = encrypt # type: ignore[method-assign] -except ImportError: - pass diff --git a/kasa/protocols/__init__.py b/kasa/protocols/__init__.py new file mode 100644 index 00000000..b994d732 --- /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 00000000..b58e57ae --- /dev/null +++ b/kasa/protocols/iotprotocol.py @@ -0,0 +1,187 @@ +"""Module for the IOT legacy IOT KASA protocol.""" + +from __future__ import annotations + +import asyncio +import logging +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::], +} + + +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: + 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: + 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 00000000..fb09b882 --- /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 00000000..324f8056 --- /dev/null +++ b/kasa/protocols/smartcamprotocol.py @@ -0,0 +1,253 @@ +"""Module for SmartCamProtocol.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from pprint import pformat as pf +from typing import Any + +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.""" + + async def _handle_response_lists( + self, response_result: dict[str, Any], method: str, retry_count: int + ) -> None: + pass + + 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) + 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 = {} + for index_id, response in enumerate(responses): + response_data = response["result"]["response_data"] + method = methods[index_id] + self._handle_response_error_code( + response_data, method, raise_on_error=False + ) + response_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 00000000..7f02b45e --- /dev/null +++ b/kasa/protocols/smartprotocol.py @@ -0,0 +1,487 @@ +"""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__) + +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::], + "device_name": lambda x: "#MASKED_NAME#" if x else "", + "device_alias": lambda x: "#MASKED_NAME#" if x else "", + "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 "", +} + +# Queries that are known not to work properly when sent as a +# multiRequest. They will not return the `method` key. +FORCE_SINGLE_REQUEST = { + "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) -> 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) + await self._handle_response_lists( + result, method, 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) + 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, retry_count=retry_count + ) + return {smart_method: result} + + async def _handle_response_lists( + self, response_result: dict[str, Any], method: str, 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: + response = await self._execute_query( + {method: {"start_index": list_length}}, + 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/smart/__init__.py b/kasa/smart/__init__.py new file mode 100644 index 00000000..09e3aba5 --- /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 00000000..815f777b --- /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 00000000..ae9fb68f --- /dev/null +++ b/kasa/smart/modules/__init__.py @@ -0,0 +1,73 @@ +"""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 .childprotection import ChildProtection +from .cloud import Cloud +from .color import Color +from .colortemperature import ColorTemperature +from .contactsensor import ContactSensor +from .devicemodule import DeviceModule +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 .motionsensor import MotionSensor +from .overheatprotection import OverheatProtection +from .reportmode import ReportMode +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", + "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", + "SmartLightEffect", + "OverheatProtection", + "HomeKit", + "Matter", +] diff --git a/kasa/smart/modules/alarm.py b/kasa/smart/modules/alarm.py new file mode 100644 index 00000000..f1bf7236 --- /dev/null +++ b/kasa/smart/modules/alarm.py @@ -0,0 +1,145 @@ +"""Implementation of alarm module.""" + +from __future__ import annotations + +from typing import Literal + +from ...feature import Feature +from ..smartmodule import SmartModule + + +class Alarm(SmartModule): + """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. + + This is implemented as some features depend on device responses. + """ + 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", + attribute_setter="set_alarm_volume", + category=Feature.Category.Config, + type=Feature.Type.Choice, + choices_getter=lambda: ["low", "normal", "high"], + ) + ) + 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) -> str: + """Return current alarm sound.""" + return self.data["get_alarm_configure"]["type"] + + async def set_alarm_sound(self, sound: str) -> dict: + """Set alarm sound. + + See *alarm_sounds* for list of available sounds. + """ + 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) -> Literal["low", "normal", "high"]: + """Return alarm volume.""" + return self.data["get_alarm_configure"]["volume"] + + async def set_alarm_volume(self, volume: Literal["low", "normal", "high"]) -> dict: + """Set alarm volume.""" + payload = self.data["get_alarm_configure"].copy() + payload["volume"] = volume + 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) -> dict: + """Play alarm.""" + return await self.call("play_alarm") + + async def stop(self) -> dict: + """Stop alarm.""" + return await self.call("stop_alarm") diff --git a/kasa/smart/modules/autooff.py b/kasa/smart/modules/autooff.py new file mode 100644 index 00000000..4fefb000 --- /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 00000000..87072b10 --- /dev/null +++ b/kasa/smart/modules/batterysensor.py @@ -0,0 +1,58 @@ +"""Implementation of battery module.""" + +from __future__ import annotations + +from ...feature import Feature +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.""" + 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) -> int: + """Return battery level.""" + return self._device.sys_info["battery_percentage"] + + @property + def battery_low(self) -> bool: + """Return True if battery is low.""" + return self._device.sys_info["at_low_battery"] diff --git a/kasa/smart/modules/brightness.py b/kasa/smart/modules/brightness.py new file mode 100644 index 00000000..b5b8d354 --- /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 00000000..4c3b99de --- /dev/null +++ b/kasa/smart/modules/childdevice.py @@ -0,0 +1,48 @@ +"""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 ..smartmodule import SmartModule + + +class ChildDevice(SmartModule): + """Implementation for child devices.""" + + REQUIRED_COMPONENT = "child_device" + QUERY_GETTER_NAME = "get_child_device_list" diff --git a/kasa/smart/modules/childprotection.py b/kasa/smart/modules/childprotection.py new file mode 100644 index 00000000..fba89cc0 --- /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/cloud.py b/kasa/smart/modules/cloud.py new file mode 100644 index 00000000..fd6d0a0f --- /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 00000000..de0c3f74 --- /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 00000000..32d6e67d --- /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/contactsensor.py b/kasa/smart/modules/contactsensor.py new file mode 100644 index 00000000..d0bebb07 --- /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 00000000..bf112e2d --- /dev/null +++ b/kasa/smart/modules/devicemodule.py @@ -0,0 +1,30 @@ +"""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.""" + query = { + "get_device_info": None, + } + # Device usage is not available on older firmware versions + # or child devices of hubs + if self.supported_version >= 2 and not self._device._is_hub_child: + query["get_device_usage"] = None + + return query diff --git a/kasa/smart/modules/energy.py b/kasa/smart/modules/energy.py new file mode 100644 index 00000000..0cfdc92c --- /dev/null +++ b/kasa/smart/modules/energy.py @@ -0,0 +1,165 @@ +"""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.""" + ma = self.data.get("get_emeter_data", {}).get("current_ma") + return ma / 1000 if ma else None + + @property + @raise_if_update_error + def voltage(self) -> float | None: + """Get the current voltage in V.""" + mv = self.data.get("get_emeter_data", {}).get("voltage_mv") + return mv / 1000 if mv else None + + 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 00000000..6443cbac --- /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 00000000..8dd3a6b3 --- /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 00000000..dd3671a0 --- /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 00000000..2df8db1f --- /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 00000000..8ce9e576 --- /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 00000000..1733c3ce --- /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 00000000..80419897 --- /dev/null +++ b/kasa/smart/modules/light.py @@ -0,0 +1,183 @@ +"""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, ColorTempRange, 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 is_color(self) -> bool: + """Whether the bulb supports color changes.""" + return Module.Color in self._device.modules + + @property + def is_dimmable(self) -> bool: + """Whether the bulb supports brightness changes.""" + return Module.Brightness in self._device.modules + + @property + def is_variable_color_temp(self) -> bool: + """Whether the bulb supports color temperature changes.""" + return Module.ColorTemperature in self._device.modules + + @property + def valid_temperature_range(self) -> ColorTempRange: + """Return the device-specific white temperature range (in Kelvin). + + :return: White temperature range in Kelvin (minimum, maximum) + """ + if Module.ColorTemperature not in self._device.modules: + raise KasaException("Color temperature not supported") + + return self._device.modules[Module.ColorTemperature].valid_temperature_range + + @property + def hsv(self) -> Annotated[HSV, FeatureAttribute()]: + """Return the current HSV state of the bulb. + + :return: hue, saturation and value (degrees, %, %) + """ + if 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: + 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: + raise KasaException("Bulb is not dimmable.") + + return await self._device.modules[Module.Brightness].set_brightness(brightness) + + @property + def has_effects(self) -> bool: + """Return True if the device supports effects.""" + return Module.LightEffect in self._device.modules + + async def set_state(self, state: LightState) -> dict: + """Set the light state.""" + state_dict = asdict(state) + # 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 00000000..96135de4 --- /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 00000000..87e96eae --- /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 00000000..91d89188 --- /dev/null +++ b/kasa/smart/modules/lightstripeffect.py @@ -0,0 +1,187 @@ +"""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 state. + + Example: + {'brightness': 50, + 'custom': 0, + 'enable': 0, + 'id': '', + '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 + 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 00000000..e623108f --- /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 00000000..c6bfe2d8 --- /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/motionsensor.py b/kasa/smart/modules/motionsensor.py new file mode 100644 index 00000000..fe9ac5c0 --- /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 00000000..cdaba4e8 --- /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/reportmode.py b/kasa/smart/modules/reportmode.py new file mode 100644 index 00000000..4765b4e1 --- /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/temperaturecontrol.py b/kasa/smart/modules/temperaturecontrol.py new file mode 100644 index 00000000..5b080461 --- /dev/null +++ b/kasa/smart/modules/temperaturecontrol.py @@ -0,0 +1,166 @@ +"""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 + + # If the states is empty, the device is idling + if not states: + return ThermostatState.Idle + + # Discard known extra states, and report on unknown extra states + states.discard("low_battery") + 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 00000000..0a591a3d --- /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 00000000..74aad4be --- /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 00000000..f986fa34 --- /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 00000000..be63ff69 --- /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 00000000..b6f01017 --- /dev/null +++ b/kasa/smart/modules/waterleaksensor.py @@ -0,0 +1,88 @@ +"""Implementation of waterleak module.""" + +from __future__ import annotations + +from datetime import datetime +from enum import Enum + +from ...feature import Feature +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 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._device.modules[Module.Time].timezone + return datetime.fromtimestamp(ts, tz=tz) diff --git a/kasa/smart/smartchilddevice.py b/kasa/smart/smartchilddevice.py new file mode 100644 index 00000000..2ef0454f --- /dev/null +++ b/kasa/smart/smartchilddevice.py @@ -0,0 +1,150 @@ +"""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.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()): + module_queries.append(module) + req.update(mod_query) + if req: + self._last_update = await self.protocol.query(req) + + 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 + + @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 00000000..5fd22115 --- /dev/null +++ b/kasa/smart/smartdevice.py @@ -0,0 +1,845 @@ +"""Module for a SMART device.""" + +from __future__ import annotations + +import base64 +import logging +import time +from collections.abc import Mapping, 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: dict[str | ModuleName[Module], SmartModule] = {} + self._parent: SmartDevice | None = None + self._children: Mapping[str, SmartDevice] = {} + self._last_update_time: float | None = None + self._on_since: datetime | None = None + self._info: dict[str, Any] = {} + + 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) + + children = self.internal_state["get_child_device_list"]["child_device_list"] + children_components_raw = { + child["device_id"]: child + for child in self.internal_state["get_child_device_component_list"][ + "child_component_list" + ] + } + from .smartchilddevice import SmartChildDevice + + self._children = { + child_info["device_id"]: await SmartChildDevice.create( + parent=self, + child_info=child_info, + child_components_raw=children_components_raw[child_info["device_id"]], + ) + for child_info in children + } + + @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() + + def _update_children_info(self) -> None: + """Update the internal child device info from the parent info.""" + if child_info := self._try_get_response( + self._last_update, "get_child_device_list", {} + ): + for info in child_info["child_device_list"]: + child_id = info["device_id"] + if child_id not in self._children: + _LOGGER.debug( + "Skipping child update for %s, probably unsupported device", + child_id, + ) + continue + self._children[child_id]._update_internal_state(info) + + def _update_internal_info(self, info_resp: dict) -> None: + """Update the internal device info.""" + self._info = self._try_get_response(info_resp, "get_device_info") + + async def update(self, update_children: bool = False) -> None: + """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) + + self._update_children_info() + # Call child update which will only update module calls, info is updated + # from get_child_device_list. update_children only affects hub devices, other + # devices will always update children to prevent errors on module access. + # This needs to go after updating the internal state of the children so that + # child modules have access to their sysinfo. + if update_children or self.device_type != DeviceType.Hub: + 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 ( + not module.update_interval + or not module._last_update_time + or (update_time - module._last_update_time) >= module.update_interval + ): + 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.error( + "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.error( + "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") + + 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, + ) + ) + + for module in self.modules.values(): + module._initialize_features() + for feat in module._module_features.values(): + self._add_feature(feat) + for child in self._children.values(): + await child._initialize_features() + + @property + def _is_hub_child(self) -> bool: + """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, child_ids: None = None + ) -> dict: + res = await self.protocol.query({method: params}) + + return res + + @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.") + + 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 + _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"] + firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1) + _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 00000000..a5666f63 --- /dev/null +++ b/kasa/smart/smartmodule.py @@ -0,0 +1,247 @@ +"""Base implementation for SMART modules.""" + +from __future__ import annotations + +import logging +from collections.abc import Awaitable, Callable, Coroutine +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], Awaitable[dict]], +) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, dict]]: + """Define a wrapper to set _last_update_time to None. + + This will ensure that a module is updated in the next update cycle after + a value has been changed. + """ + + async def _async_wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> dict: + 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.""" + + 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 + 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 is None: + return self.MINIMUM_UPDATE_INTERVAL_SECS + + return self.UPDATE_INTERVAL_AFTER_ERROR_SECS * self._error_count + + @property + def disabled(self) -> bool: + """Return true if the module is disabled due to errors.""" + return self._error_count >= self.DISABLE_AFTER_ERROR_COUNT + + @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/smartcam/__init__.py b/kasa/smartcam/__init__.py new file mode 100644 index 00000000..574459f4 --- /dev/null +++ b/kasa/smartcam/__init__.py @@ -0,0 +1,5 @@ +"""Package for supporting tapo-branded cameras.""" + +from .smartcamdevice import SmartCamDevice + +__all__ = ["SmartCamDevice"] diff --git a/kasa/smartcam/modules/__init__.py b/kasa/smartcam/modules/__init__.py new file mode 100644 index 00000000..3ea4bb6a --- /dev/null +++ b/kasa/smartcam/modules/__init__.py @@ -0,0 +1,33 @@ +"""Modules for SMARTCAM devices.""" + +from .alarm import Alarm +from .babycrydetection import BabyCryDetection +from .camera import Camera +from .childdevice import ChildDevice +from .device import DeviceModule +from .homekit import HomeKit +from .led import Led +from .lensmask import LensMask +from .matter import Matter +from .motiondetection import MotionDetection +from .pantilt import PanTilt +from .persondetection import PersonDetection +from .tamperdetection import TamperDetection +from .time import Time + +__all__ = [ + "Alarm", + "BabyCryDetection", + "Camera", + "ChildDevice", + "DeviceModule", + "Led", + "PanTilt", + "PersonDetection", + "Time", + "HomeKit", + "Matter", + "MotionDetection", + "LensMask", + "TamperDetection", +] diff --git a/kasa/smartcam/modules/alarm.py b/kasa/smartcam/modules/alarm.py new file mode 100644 index 00000000..12d43464 --- /dev/null +++ b/kasa/smartcam/modules/alarm.py @@ -0,0 +1,166 @@ +"""Implementation of alarm module.""" + +from __future__ import annotations + +from ...feature import Feature +from ..smartcammodule import SmartCamModule + +DURATION_MIN = 0 +DURATION_MAX = 6000 + +VOLUME_MIN = 0 +VOLUME_MAX = 10 + + +class Alarm(SmartCamModule): + """Implementation of alarm module.""" + + # Needs a different name to avoid clashing with SmartAlarm + NAME = "SmartCamAlarm" + + REQUIRED_COMPONENT = "siren" + QUERY_GETTER_NAME = "getSirenStatus" + QUERY_MODULE_NAME = "siren" + + 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) -> str: + """Return current alarm sound.""" + return self.data["getSirenConfig"]["siren_type"] + + async def set_alarm_sound(self, sound: str) -> dict: + """Set alarm sound. + + See *alarm_sounds* for list of available sounds. + """ + if sound not in self.alarm_sounds: + raise ValueError( + f"sound must be one of {', '.join(self.alarm_sounds)}: {sound}" + ) + return await self.call("setSirenConfig", {"siren": {"siren_type": sound}}) + + @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) -> int: + """Return alarm volume. + + Unlike duration the device expects/returns a string for volume. + """ + return int(self.data["getSirenConfig"]["volume"]) + + async def set_alarm_volume(self, volume: int) -> dict: + """Set alarm volume.""" + if volume < VOLUME_MIN or volume > VOLUME_MAX: + raise ValueError(f"volume must be between {VOLUME_MIN} and {VOLUME_MAX}") + return await self.call("setSirenConfig", {"siren": {"volume": str(volume)}}) + + @property + def alarm_duration(self) -> int: + """Return alarm duration.""" + return self.data["getSirenConfig"]["duration"] + + async def set_alarm_duration(self, duration: int) -> dict: + """Set alarm volume.""" + if duration < DURATION_MIN or duration > DURATION_MAX: + msg = f"duration must be between {DURATION_MIN} and {DURATION_MAX}" + raise ValueError(msg) + return await self.call("setSirenConfig", {"siren": {"duration": duration}}) + + @property + def active(self) -> bool: + """Return true if alarm is active.""" + return self.data["getSirenStatus"]["status"] != "off" + + async def play(self) -> dict: + """Play alarm.""" + return await self.call("setSirenStatus", {"siren": {"status": "on"}}) + + async def stop(self) -> dict: + """Stop alarm.""" + return await self.call("setSirenStatus", {"siren": {"status": "off"}}) diff --git a/kasa/smartcam/modules/babycrydetection.py b/kasa/smartcam/modules/babycrydetection.py new file mode 100644 index 00000000..e9e32371 --- /dev/null +++ b/kasa/smartcam/modules/babycrydetection.py @@ -0,0 +1,47 @@ +"""Implementation of baby cry detection module.""" + +from __future__ import annotations + +import logging + +from ...feature import Feature +from ..smartcammodule import SmartCamModule + +_LOGGER = logging.getLogger(__name__) + + +class BabyCryDetection(SmartCamModule): + """Implementation of baby cry detection module.""" + + REQUIRED_COMPONENT = "babyCryDetection" + + QUERY_GETTER_NAME = "getBCDConfig" + QUERY_MODULE_NAME = "sound_detection" + QUERY_SECTION_NAMES = "bcd" + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + self._device, + id="baby_cry_detection", + name="Baby cry detection", + container=self, + attribute_getter="enabled", + attribute_setter="set_enabled", + type=Feature.Type.Switch, + category=Feature.Category.Primary, + ) + ) + + @property + def enabled(self) -> bool: + """Return the baby cry detection enabled state.""" + return self.data["bcd"]["enabled"] == "on" + + async def set_enabled(self, enable: bool) -> dict: + """Set the baby cry detection enabled state.""" + params = {"enabled": "on" if enable else "off"} + return await self._device._query_setter_helper( + "setBCDConfig", self.QUERY_MODULE_NAME, "bcd", params + ) diff --git a/kasa/smartcam/modules/camera.py b/kasa/smartcam/modules/camera.py new file mode 100644 index 00000000..f1eda0f9 --- /dev/null +++ b/kasa/smartcam/modules/camera.py @@ -0,0 +1,126 @@ +"""Implementation of camera module.""" + +from __future__ import annotations + +import base64 +import logging +from enum import StrEnum +from typing import Annotated +from urllib.parse import quote_plus + +from ...credentials import Credentials +from ...device_type import DeviceType +from ...feature import Feature +from ...json import loads as json_loads +from ...module import FeatureAttribute, Module +from ..smartcammodule import SmartCamModule + +_LOGGER = logging.getLogger(__name__) + +LOCAL_STREAMING_PORT = 554 +ONVIF_PORT = 2020 + + +class StreamResolution(StrEnum): + """Class for stream resolution.""" + + HD = "HD" + SD = "SD" + + +class Camera(SmartCamModule): + """Implementation of device module.""" + + 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. + """ + 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.""" + return f"http://{self._device.host}:{ONVIF_PORT}/onvif/device_service" + + async def _check_supported(self) -> bool: + """Additional check to see if the module is supported by the device.""" + return self._device.device_type is DeviceType.Camera diff --git a/kasa/smartcam/modules/childdevice.py b/kasa/smartcam/modules/childdevice.py new file mode 100644 index 00000000..c4de5838 --- /dev/null +++ b/kasa/smartcam/modules/childdevice.py @@ -0,0 +1,26 @@ +"""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. + """ + return {self.QUERY_GETTER_NAME: {"childControl": {"start_index": 0}}} + + 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/device.py b/kasa/smartcam/modules/device.py new file mode 100644 index 00000000..655a92da --- /dev/null +++ b/kasa/smartcam/modules/device.py @@ -0,0 +1,83 @@ +"""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.""" + 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.data[self.QUERY_GETTER_NAME]["basic_info"]["dev_id"] + + @property + def rssi(self) -> int | None: + """Return the device id.""" + return self.data["getConnectionType"].get("rssiValue") + + @property + def signal_level(self) -> int | None: + """Return the device id.""" + return self.data["getConnectionType"].get("rssi") diff --git a/kasa/smartcam/modules/homekit.py b/kasa/smartcam/modules/homekit.py new file mode 100644 index 00000000..a35de4f9 --- /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 00000000..fb62c52d --- /dev/null +++ b/kasa/smartcam/modules/led.py @@ -0,0 +1,28 @@ +"""Module for led controls.""" + +from __future__ import annotations + +from ...interfaces.led import Led as LedInterface +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" + + 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 00000000..9257b306 --- /dev/null +++ b/kasa/smartcam/modules/lensmask.py @@ -0,0 +1,31 @@ +"""Implementation of lens mask privacy module.""" + +from __future__ import annotations + +import logging + +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" + + 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/matter.py b/kasa/smartcam/modules/matter.py new file mode 100644 index 00000000..8ea0e4cf --- /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/motiondetection.py b/kasa/smartcam/modules/motiondetection.py new file mode 100644 index 00000000..33067bdf --- /dev/null +++ b/kasa/smartcam/modules/motiondetection.py @@ -0,0 +1,47 @@ +"""Implementation of motion detection module.""" + +from __future__ import annotations + +import logging + +from ...feature import Feature +from ..smartcammodule import SmartCamModule + +_LOGGER = logging.getLogger(__name__) + + +class MotionDetection(SmartCamModule): + """Implementation of motion detection module.""" + + REQUIRED_COMPONENT = "detection" + + QUERY_GETTER_NAME = "getDetectionConfig" + QUERY_MODULE_NAME = "motion_detection" + QUERY_SECTION_NAMES = "motion_det" + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + self._device, + id="motion_detection", + name="Motion detection", + container=self, + attribute_getter="enabled", + attribute_setter="set_enabled", + type=Feature.Type.Switch, + category=Feature.Category.Primary, + ) + ) + + @property + def enabled(self) -> bool: + """Return the motion detection enabled state.""" + return self.data["motion_det"]["enabled"] == "on" + + async def set_enabled(self, enable: bool) -> dict: + """Set the motion detection enabled state.""" + params = {"enabled": "on" if enable else "off"} + return await self._device._query_setter_helper( + "setDetectionConfig", self.QUERY_MODULE_NAME, "motion_det", params + ) diff --git a/kasa/smartcam/modules/pantilt.py b/kasa/smartcam/modules/pantilt.py new file mode 100644 index 00000000..fb647f6f --- /dev/null +++ b/kasa/smartcam/modules/pantilt.py @@ -0,0 +1,107 @@ +"""Implementation of time 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 device_local_time.""" + + REQUIRED_COMPONENT = "ptz" + _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, + ) + ) + + def query(self) -> dict: + """Query to execute during the update cycle.""" + return {} + + 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)}}}} + ) diff --git a/kasa/smartcam/modules/persondetection.py b/kasa/smartcam/modules/persondetection.py new file mode 100644 index 00000000..641609d5 --- /dev/null +++ b/kasa/smartcam/modules/persondetection.py @@ -0,0 +1,47 @@ +"""Implementation of person detection module.""" + +from __future__ import annotations + +import logging + +from ...feature import Feature +from ..smartcammodule import SmartCamModule + +_LOGGER = logging.getLogger(__name__) + + +class PersonDetection(SmartCamModule): + """Implementation of person detection module.""" + + REQUIRED_COMPONENT = "personDetection" + + QUERY_GETTER_NAME = "getPersonDetectionConfig" + QUERY_MODULE_NAME = "people_detection" + QUERY_SECTION_NAMES = "detection" + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + self._device, + id="person_detection", + name="Person detection", + container=self, + attribute_getter="enabled", + attribute_setter="set_enabled", + type=Feature.Type.Switch, + category=Feature.Category.Primary, + ) + ) + + @property + def enabled(self) -> bool: + """Return the person detection enabled state.""" + return self.data["detection"]["enabled"] == "on" + + async def set_enabled(self, enable: bool) -> dict: + """Set the person detection enabled state.""" + params = {"enabled": "on" if enable else "off"} + return await self._device._query_setter_helper( + "setPersonDetectionConfig", self.QUERY_MODULE_NAME, "detection", params + ) diff --git a/kasa/smartcam/modules/tamperdetection.py b/kasa/smartcam/modules/tamperdetection.py new file mode 100644 index 00000000..32b352f7 --- /dev/null +++ b/kasa/smartcam/modules/tamperdetection.py @@ -0,0 +1,47 @@ +"""Implementation of tamper detection module.""" + +from __future__ import annotations + +import logging + +from ...feature import Feature +from ..smartcammodule import SmartCamModule + +_LOGGER = logging.getLogger(__name__) + + +class TamperDetection(SmartCamModule): + """Implementation of tamper detection module.""" + + REQUIRED_COMPONENT = "tamperDetection" + + QUERY_GETTER_NAME = "getTamperDetectionConfig" + QUERY_MODULE_NAME = "tamper_detection" + QUERY_SECTION_NAMES = "tamper_det" + + def _initialize_features(self) -> None: + """Initialize features after the initial update.""" + self._add_feature( + Feature( + self._device, + id="tamper_detection", + name="Tamper detection", + container=self, + attribute_getter="enabled", + attribute_setter="set_enabled", + type=Feature.Type.Switch, + category=Feature.Category.Primary, + ) + ) + + @property + def enabled(self) -> bool: + """Return the tamper detection enabled state.""" + return self.data["tamper_det"]["enabled"] == "on" + + async def set_enabled(self, enable: bool) -> dict: + """Set the tamper detection enabled state.""" + params = {"enabled": "on" if enable else "off"} + return await self._device._query_setter_helper( + "setTamperDetectionConfig", self.QUERY_MODULE_NAME, "tamper_det", params + ) diff --git a/kasa/smartcam/modules/time.py b/kasa/smartcam/modules/time.py new file mode 100644 index 00000000..4e5cb8df --- /dev/null +++ b/kasa/smartcam/modules/time.py @@ -0,0 +1,90 @@ +"""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 ..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 + + 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/smartcamdevice.py b/kasa/smartcam/smartcamdevice.py new file mode 100644 index 00000000..fdae3140 --- /dev/null +++ b/kasa/smartcam/smartcamdevice.py @@ -0,0 +1,269 @@ +"""Module for SmartCamDevice.""" + +from __future__ import annotations + +import logging +from typing import Any, cast + +from ..device import DeviceInfo +from ..device_type import DeviceType +from ..module import Module +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} + + @staticmethod + def _get_device_type_from_sysinfo(sysinfo: dict[str, Any]) -> DeviceType: + """Find type to be displayed as a supported device category.""" + if ( + sysinfo + and (device_type := sysinfo.get("device_type")) + and device_type.endswith("HUB") + ): + return DeviceType.Hub + 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"] + firmware_version, firmware_build = fw_version_full.split(" ", maxsplit=1) + 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_children_info(self) -> None: + """Update the internal child device info from the parent info.""" + if child_info := self._try_get_response( + self._last_update, "getChildDeviceList", {} + ): + for info in child_info["child_device_list"]: + child_id = info["device_id"] + if child_id not in self._children: + _LOGGER.debug( + "Skipping child update for %s, probably unsupported device", + child_id, + ) + continue + self._children[child_id]._update_internal_state(info) + + 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_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) + + smart_children_components = { + child["device_id"]: child + for child in resp["getChildDeviceComponentList"]["child_component_list"] + } + children = {} + for info in resp["getChildDeviceList"]["child_device_list"]: + if ( + (category := info.get("category")) + and category in SmartChildDevice.CHILD_DEVICE_TYPE_MAP + and (child_id := info.get("device_id")) + and (child_components := smart_children_components.get(child_id)) + ): + children[child_id] = await self._initialize_smart_child( + info, child_components + ) + else: + _LOGGER.debug("Child device type not supported: %s", info) + + self._children = children + + 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 + # Always add Camera module to cameras + and ( + mod._module_name() != Module.Camera + or self._device_type is not DeviceType.Camera + ) + ): + 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) + + for child in self._children.values(): + await child._initialize_features() + + async def _query_setter_helper( + self, method: str, module: str, section: str, params: dict | None = None + ) -> dict: + res = await self.protocol.query({method: {module: {section: params}}}) + + return res + + async def _query_getter_helper( + self, method: str, module: str, sections: str | list[str] + ) -> Any: + res = await self.protocol.query({method: {module: {"name": sections}}}) + + return res + + @staticmethod + def _parse_components(components_raw: ComponentsRaw) -> dict[str, int]: + return { + str(comp["name"]): int(comp["version"]) + for comp in components_raw["app_component_list"] + } + + async def _negotiate(self) -> None: + """Perform initialization. + + 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: + basic_info = device_info["basic_info"] + return { + "model": basic_info["device_model"], + "device_type": basic_info["device_type"], + "alias": basic_info["device_alias"], + "fw_ver": basic_info["sw_version"], + "hw_ver": basic_info["hw_version"], + "mac": basic_info["mac"], + "hwId": basic_info.get("hw_id"), + "oem_id": basic_info["oem_id"], + "device_id": basic_info["dev_id"], + } + + @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: + 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 diff --git a/kasa/smartcam/smartcammodule.py b/kasa/smartcam/smartcammodule.py new file mode 100644 index 00000000..85addd65 --- /dev/null +++ b/kasa/smartcam/smartcammodule.py @@ -0,0 +1,120 @@ +"""Base implementation for SMART modules.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any, Final, cast + +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" + ) + SmartCamTamperDetection: Final[ModuleName[modules.TamperDetection]] = ModuleName( + "TamperDetection" + ) + SmartCamBabyCryDetection: Final[ModuleName[modules.BabyCryDetection]] = ModuleName( + "BabyCryDetection" + ) + + 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. + """ + if params: + module = next(iter(params)) + section = next(iter(params[module])) + else: + module = "system" + section = "null" + + if method[:3] == "get": + return await self._device._query_getter_helper(method, module, section) + + if TYPE_CHECKING: + params = cast(dict[str, dict[str, Any]], params) + return await self._device._query_setter_helper( + method, module, section, params[module][section] + ) + + @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 4249807f..00000000 --- a/kasa/smartdevice.py +++ /dev/null @@ -1,833 +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 collections.abc -import functools -import inspect -import logging -from dataclasses import dataclass -from datetime import datetime, timedelta -from typing import Any, Dict, List, Optional, Set - -from .credentials import Credentials -from .device_type import DeviceType -from .deviceconfig import DeviceConfig -from .emeterstatus import EmeterStatus -from .exceptions import SmartDeviceException -from .modules import Emeter, Module -from .protocol import TPLinkProtocol, TPLinkSmartHomeProtocol, _XorTransport - -_LOGGER = logging.getLogger(__name__) - - -@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 - - # For SMART devices - signal_level: Optional[int] = None - - -def merge(d, u): - """Update dict recursively.""" - for k, v in u.items(): - if isinstance(v, collections.abc.Mapping): - d[k] = merge(d.get(k, {}), v) - else: - d[k] = v - return d - - -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 and f.__name__ not in self._sys_info: - 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 and f.__name__ not in self._sys_info: - raise SmartDeviceException( - "You need to await update() to access the data" - ) - return f(*args, **kwargs) - - f.requires_update = True - return wrapped - - -@functools.lru_cache -def _parse_features(features: str) -> Set[str]: - """Parse features string.""" - return set(features.split(":")) - - -class SmartDevice: - """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:`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 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': '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 - - >>> 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: Optional[DeviceConfig] = None, - protocol: Optional[TPLinkProtocol] = None, - ) -> None: - """Create a new SmartDevice instance. - - :param str host: host name or ip address on which the device listens - """ - if config and protocol: - protocol._transport._config = config - self.protocol: TPLinkProtocol = protocol or TPLinkSmartHomeProtocol( - transport=_XorTransport(config=config or DeviceConfig(host=host)), - ) - _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._discovery_info: Optional[Dict[str, Any]] = None - - self._sys_info: Any = None # TODO: this is here to avoid changing tests - self._features: Set[str] = set() - self.modules: Dict[str, Any] = {} - - self.children: List["SmartDevice"] = [] - - @property - def host(self) -> str: - """The device host.""" - return self.protocol._transport._host - - @host.setter - def host(self, value): - """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) -> Optional[Credentials]: - """The device credentials.""" - return self.protocol._transport._credentials - - @property - def credentials_hash(self) -> Optional[str]: - """The protocol specific hash of the credentials the device is using.""" - return self.protocol._transport.credentials_hash - - def add_module(self, name: str, module: Module): - """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: 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 - - def _verify_emeter(self) -> None: - """Raise an exception if there is no emeter.""" - if not self.has_emeter: - raise SmartDeviceException("Device has no emeter") - if self.emeter_type not in self._last_update: - raise SmartDeviceException("update() required prior accessing emeter") - - 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(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 features(self) -> Set[str]: - """Return a set of features that the device supports.""" - return self._features - - @property # type: ignore - @requires_update - def supported_modules(self) -> List[str]: - """Return a set of modules supported by the device.""" - # TODO: this should rather be called `features`, but we don't want to break - # the API now. Maybe just deprecate it and point the users to use this? - return list(self.modules.keys()) - - @property # type: ignore - @requires_update - def has_emeter(self) -> bool: - """Return True if device has an energy meter.""" - return "ENE" in self.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): - """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 self._last_update is None: - _LOGGER.debug("Performing the initial update to obtain sysinfo") - response = await self.protocol.query(req) - self._last_update = response - self._set_sys_info(response["system"]["get_sysinfo"]) - - await self._modular_update(req) - self._set_sys_info(self._last_update["system"]["get_sysinfo"]) - - async def _modular_update(self, req: dict) -> None: - """Execute an update query.""" - if self.has_emeter: - _LOGGER.debug( - "The device has emeter, querying its information along sysinfo" - ) - self.add_module("emeter", Emeter(self, self.emeter_type)) - - 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: - update = {**update, **response} - self._last_update = update - - 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 - self._set_sys_info(info) - - 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._features = _parse_features(features) - else: - self._features = set() - - @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 # type: ignore - @requires_update - def model(self) -> str: - """Return device model.""" - sys_info = self._sys_info - return str(sys_info["model"]) - - @property - def has_children(self) -> bool: - """Return true if the device has children devices.""" - # Ideally we would check for the 'child_num' key in sys_info, - # but devices that speak klap do not populate this key via - # update_from_discover_info so we check for the devices - # we know have children instead. - return self.is_strip - - @property # type: ignore - def alias(self) -> Optional[str]: - """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) -> None: - """Set the device name (alias).""" - return await self._query_helper("system", "set_dev_alias", {"alias": alias}) - - @property # type: ignore - @requires_update - def time(self) -> datetime: - """Return current time from the device.""" - return self.modules["time"].time - - @property # type: ignore - @requires_update - def timezone(self) -> Dict: - """Return the current timezone.""" - return self.modules["time"].timezone - - async def get_time(self) -> Optional[datetime]: - """Return current time from the device, if available.""" - _LOGGER.warning( - "Use `time` property instead, this call will be removed in the future." - ) - return await self.modules["time"].get_time() - - async def get_timezone(self) -> Dict: - """Return timezone information.""" - _LOGGER.warning( - "Use `timezone` property instead, this call will be removed in the future." - ) - return await self.modules["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"] / 10000 - loc["longitude"] = sys_info["longitude_i"] / 10000 - else: - _LOGGER.debug("Unsupported device location.") - - return loc - - @property # type: ignore - @requires_update - def rssi(self) -> Optional[int]: - """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 SmartDeviceException( - "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): - """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.""" - self._verify_emeter() - return EmeterStatus(self.modules["emeter"].realtime) - - async def get_emeter_realtime(self) -> EmeterStatus: - """Retrieve current energy readings.""" - self._verify_emeter() - return EmeterStatus(await self.modules["emeter"].get_realtime()) - - @property # type: ignore - @requires_update - def emeter_today(self) -> Optional[float]: - """Return today's energy consumption in kWh.""" - self._verify_emeter() - return self.modules["emeter"].emeter_today - - @property # type: ignore - @requires_update - def emeter_this_month(self) -> Optional[float]: - """Return this month's energy consumption in kWh.""" - self._verify_emeter() - return self.modules["emeter"].emeter_this_month - - async def get_emeter_daily( - self, year: Optional[int] = None, month: Optional[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 - """ - self._verify_emeter() - return await self.modules["emeter"].get_daystat(year=year, month=month, kwh=kwh) - - @requires_update - async def get_emeter_monthly( - self, year: Optional[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 - """ - self._verify_emeter() - return await self.modules["emeter"].get_monthstat(year=year, kwh=kwh) - - @requires_update - async def erase_emeter_stats(self) -> Dict: - """Erase energy meter statistics.""" - self._verify_emeter() - return await self.modules["emeter"].erase_stats() - - @requires_update - async def current_consumption(self) -> float: - """Get the current power consumption in Watt.""" - self._verify_emeter() - response = self.emeter_realtime - return float(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().replace(microsecond=0) - 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. - - 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): - 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: str, password: str, keytype: str = "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": int(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 max_device_response_size(self) -> int: - """Returns the maximum response size the device can safely construct.""" - return 16 * 1024 - - @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_strip_socket(self) -> bool: - """Return True if the device is a strip socket.""" - return self._device_type == DeviceType.StripSocket - - @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 - - @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 - - 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}" - f" ({self.alias}), is_on: {self.is_on}" - f" - dev specific: {self.state_information}>" - ) - - @property - def config(self) -> DeviceConfig: - """Return the device configuration.""" - return self.protocol.config - - @staticmethod - async def connect( - *, - host: Optional[str] = None, - config: Optional[DeviceConfig] = None, - ) -> "SmartDevice": - """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] diff --git a/kasa/smartlightstrip.py b/kasa/smartlightstrip.py deleted file mode 100644 index 27ebf838..00000000 --- a/kasa/smartlightstrip.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Module for light strips (KL430).""" -from typing import Any, Dict, List, Optional - -from .deviceconfig import DeviceConfig -from .effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 -from .protocol import TPLinkProtocol -from .smartbulb import SmartBulb -from .smartdevice import DeviceType, SmartDeviceException, 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, - *, - config: Optional[DeviceConfig] = None, - protocol: Optional[TPLinkProtocol] = None, - ) -> None: - super().__init__(host=host, config=config, protocol=protocol) - 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 effect_list(self) -> Optional[List[str]]: - """Return built-in effects list. - - Example: - ['Aurora', 'Bubbling Cauldron', ...] - """ - return EFFECT_NAMES_V1 if self.has_effects else None - - @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 - if self.has_effects: - info["Effect"] = self.effect["name"] - - return info - - @requires_update - async def set_effect( - self, - effect: str, - *, - brightness: Optional[int] = None, - transition: Optional[int] = None, - ) -> None: - """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 not in EFFECT_MAPPING_V1: - raise SmartDeviceException(f"The effect {effect} is not a built in effect.") - effect_dict = EFFECT_MAPPING_V1[effect] - if brightness is not None: - effect_dict["brightness"] = brightness - if transition is not None: - effect_dict["transition"] = transition - - await self.set_custom_effect(effect_dict) - - @requires_update - async def set_custom_effect( - self, - effect_dict: Dict, - ) -> None: - """Set a custom effect on the device. - - :param str effect_dict: The custom effect dict to set - """ - if not self.has_effects: - raise SmartDeviceException("Bulb does not support effects.") - await self._query_helper( - "smartlife.iot.lighting_effect", - "set_lighting_effect", - effect_dict, - ) diff --git a/kasa/smartplug.py b/kasa/smartplug.py deleted file mode 100644 index d9ac0c86..00000000 --- a/kasa/smartplug.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Module for smart plugs (HS100, HS110, ..).""" -import logging -from typing import Any, Dict, Optional - -from kasa.deviceconfig import DeviceConfig -from kasa.modules import Antitheft, Cloud, Schedule, Time, Usage -from kasa.protocol import TPLinkProtocol -from kasa.smartdevice import DeviceType, SmartDevice, requires_update - -_LOGGER = logging.getLogger(__name__) - - -class SmartPlug(SmartDevice): - r"""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, - *, - config: Optional[DeviceConfig] = None, - protocol: Optional[TPLinkProtocol] = None, - ) -> None: - super().__init__(host=host, config=config, protocol=protocol) - self._device_type = DeviceType.Plug - self.add_module("schedule", Schedule(self, "schedule")) - self.add_module("usage", Usage(self, "schedule")) - self.add_module("antitheft", Antitheft(self, "anti_theft")) - self.add_module("time", Time(self, "time")) - self.add_module("cloud", Cloud(self, "cnCloud")) - - @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/smartprotocol.py b/kasa/smartprotocol.py deleted file mode 100644 index 97573d93..00000000 --- a/kasa/smartprotocol.py +++ /dev/null @@ -1,272 +0,0 @@ -"""Implementation of the TP-Link AES Protocol. - -Based on the work of https://github.com/petretiandrea/plugp100 -under compatible GNU GPL3 license. -""" - -import asyncio -import base64 -import logging -import time -import uuid -from pprint import pformat as pf -from typing import Dict, Union - -import httpx - -from .exceptions import ( - SMART_AUTHENTICATION_ERRORS, - SMART_RETRYABLE_ERRORS, - SMART_TIMEOUT_ERRORS, - AuthenticationException, - RetryableException, - SmartDeviceException, - SmartErrorCode, - TimeoutException, -) -from .json import dumps as json_dumps -from .protocol import BaseTransport, TPLinkProtocol, md5 - -_LOGGER = logging.getLogger(__name__) -logging.getLogger("httpx").propagate = False - - -class SmartProtocol(TPLinkProtocol): - """Class for the new TPLink SMART protocol.""" - - SLEEP_SECONDS_AFTER_TIMEOUT = 1 - - 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._request_id_generator = SnowflakeId(1, 1) - self._query_lock = asyncio.Lock() - - def get_smart_request(self, method, params=None) -> str: - """Get a request message as a string.""" - request = { - "method": method, - "params": params, - "requestID": self._request_id_generator.generate_id(), - "request_time_milis": round(time.time() * 1000), - "terminal_uuid": self._terminal_uuid, - } - return json_dumps(request) - - async def query(self, request: Union[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: Union[str, Dict], retry_count: int = 3) -> Dict: - for retry in range(retry_count + 1): - try: - return await self._execute_query(request, retry) - except httpx.ConnectError as sdex: - if retry >= retry_count: - await self.close() - _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) - raise SmartDeviceException( - f"Unable to connect to the device: {self._host}: {sdex}" - ) from sdex - continue - except TimeoutError as tex: - if retry >= retry_count: - await self.close() - raise SmartDeviceException( - "Unable to connect to the device, " - + f"timed out: {self._host}: {tex}" - ) from tex - await asyncio.sleep(self.SLEEP_SECONDS_AFTER_TIMEOUT) - continue - except AuthenticationException as auex: - await self.close() - _LOGGER.debug( - "Unable to authenticate with %s, not retrying", self._host - ) - raise auex - except RetryableException as ex: - if retry >= retry_count: - await self.close() - _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) - raise ex - continue - except TimeoutException as ex: - if retry >= retry_count: - await self.close() - _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) - raise ex - await asyncio.sleep(self.SLEEP_SECONDS_AFTER_TIMEOUT) - continue - except SmartDeviceException as ex: - # Transport would have raised RetryableException if retry makes sense. - await self.close() - _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) - raise ex - except Exception as ex: - if retry >= retry_count: - await self.close() - _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) - raise SmartDeviceException( - f"Unable to connect to the device: {self._host}: {ex}" - ) from ex - _LOGGER.debug( - "Unable to query the device %s, retrying: %s", self._host, ex - ) - continue - - # make mypy happy, this should never be reached.. - raise SmartDeviceException("Query reached somehow to unreachable") - - async def _execute_query(self, request: Union[str, Dict], retry_count: int) -> Dict: - if isinstance(request, dict): - if len(request) == 1: - smart_method = next(iter(request)) - smart_params = request[smart_method] - else: - requests = [] - for method, params in request.items(): - requests.append({"method": method, "params": params}) - smart_method = "multipleRequest" - smart_params = {"requests": requests} - else: - smart_method = request - smart_params = None - - smart_request = self.get_smart_request(smart_method, smart_params) - _LOGGER.debug( - "%s >> %s", - self._host, - _LOGGER.isEnabledFor(logging.DEBUG) and pf(smart_request), - ) - response_data = await self._transport.send(smart_request) - - _LOGGER.debug( - "%s << %s", - self._host, - _LOGGER.isEnabledFor(logging.DEBUG) and pf(response_data), - ) - - self._handle_response_error_code(response_data) - - if (result := response_data.get("result")) is None: - # Single set_ requests do not return a result - return {smart_method: None} - - if (responses := result.get("responses")) is None: - return {smart_method: result} - - # responses is returned for multipleRequest - multi_result = {} - for response in responses: - self._handle_response_error_code(response) - result = response.get("result", None) - multi_result[response["method"]] = result - return multi_result - - def _handle_response_error_code(self, resp_dict: dict): - error_code = SmartErrorCode(resp_dict.get("error_code")) # type: ignore[arg-type] - if error_code == SmartErrorCode.SUCCESS: - return - msg = ( - f"Error querying device: {self._host}: " - + f"{error_code.name}({error_code.value})" - ) - if method := resp_dict.get("method"): - msg += f" for method: {method}" - if error_code in SMART_TIMEOUT_ERRORS: - raise TimeoutException(msg, error_code=error_code) - if error_code in SMART_RETRYABLE_ERRORS: - raise RetryableException(msg, error_code=error_code) - if error_code in SMART_AUTHENTICATION_ERRORS: - raise AuthenticationException(msg, error_code=error_code) - raise SmartDeviceException(msg, error_code=error_code) - - async def close(self) -> None: - """Close the protocol.""" - await self._transport.close() - - -class SnowflakeId: - """Class for generating snowflake ids.""" - - EPOCH = 1420041600000 # Custom epoch (in milliseconds) - WORKER_ID_BITS = 5 - DATA_CENTER_ID_BITS = 5 - SEQUENCE_BITS = 12 - - MAX_WORKER_ID = (1 << WORKER_ID_BITS) - 1 - MAX_DATA_CENTER_ID = (1 << DATA_CENTER_ID_BITS) - 1 - - SEQUENCE_MASK = (1 << SEQUENCE_BITS) - 1 - - def __init__(self, worker_id, data_center_id): - if worker_id > SnowflakeId.MAX_WORKER_ID or worker_id < 0: - raise ValueError( - "Worker ID can't be greater than " - + str(SnowflakeId.MAX_WORKER_ID) - + " or less than 0" - ) - if data_center_id > SnowflakeId.MAX_DATA_CENTER_ID or data_center_id < 0: - raise ValueError( - "Data center ID can't be greater than " - + str(SnowflakeId.MAX_DATA_CENTER_ID) - + " or less than 0" - ) - - self.worker_id = worker_id - self.data_center_id = data_center_id - self.sequence = 0 - self.last_timestamp = -1 - - def generate_id(self): - """Generate a snowflake id.""" - timestamp = self._current_millis() - - if timestamp < self.last_timestamp: - raise ValueError("Clock moved backwards. Refusing to generate ID.") - - if timestamp == self.last_timestamp: - # Within the same millisecond, increment the sequence number - self.sequence = (self.sequence + 1) & SnowflakeId.SEQUENCE_MASK - if self.sequence == 0: - # Sequence exceeds its bit range, wait until the next millisecond - timestamp = self._wait_next_millis(self.last_timestamp) - else: - # New millisecond, reset the sequence number - self.sequence = 0 - - # Update the last timestamp - self.last_timestamp = timestamp - - # Generate and return the final ID - return ( - ( - (timestamp - SnowflakeId.EPOCH) - << ( - SnowflakeId.WORKER_ID_BITS - + SnowflakeId.SEQUENCE_BITS - + SnowflakeId.DATA_CENTER_ID_BITS - ) - ) - | ( - self.data_center_id - << (SnowflakeId.SEQUENCE_BITS + SnowflakeId.WORKER_ID_BITS) - ) - | (self.worker_id << SnowflakeId.SEQUENCE_BITS) - | self.sequence - ) - - def _current_millis(self): - return round(time.time() * 1000) - - def _wait_next_millis(self, last_timestamp): - timestamp = self._current_millis() - while timestamp <= last_timestamp: - timestamp = self._current_millis() - return timestamp diff --git a/kasa/smartstrip.py b/kasa/smartstrip.py deleted file mode 100755 index f9266abd..00000000 --- a/kasa/smartstrip.py +++ /dev/null @@ -1,381 +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, - EmeterStatus, - SmartDevice, - SmartDeviceException, - merge, - requires_update, -) -from kasa.smartplug import SmartPlug - -from .deviceconfig import DeviceConfig -from .modules import Antitheft, Countdown, Emeter, Schedule, Time, Usage -from .protocol import TPLinkProtocol - -_LOGGER = logging.getLogger(__name__) - - -def merge_sums(dicts): - """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 SmartStrip(SmartDevice): - 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:`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, - *, - config: Optional[DeviceConfig] = None, - protocol: Optional[TPLinkProtocol] = None, - ) -> None: - super().__init__(host=host, config=config, protocol=protocol) - self.emeter_type = "emeter" - self._device_type = DeviceType.Strip - self.add_module("antitheft", Antitheft(self, "anti_theft")) - self.add_module("schedule", Schedule(self, "schedule")) - self.add_module("usage", Usage(self, "schedule")) - self.add_module("time", Time(self, "time")) - self.add_module("countdown", Countdown(self, "countdown")) - self.add_module("emeter", Emeter(self, "emeter")) - - @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): - """Update some of the attributes. - - Needed for methods that are decorated with `requires_update`. - """ - await super().update(update_children) - - # 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"]) - ) - - if update_children and self.has_emeter: - for plug in self.children: - await plug.update() - - async def turn_on(self, **kwargs): - """Turn the strip on.""" - for plug in self.children: - await plug.turn_on() - - async def turn_off(self, **kwargs): - """Turn the strip off.""" - for plug in self.children: - await plug.turn_off() - - @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)}) - - @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.""" - return sum([await plug.current_consumption() for plug in self.children]) - - @requires_update - async def get_emeter_realtime(self) -> EmeterStatus: - """Retrieve current energy readings.""" - emeter_rt = await self._async_get_emeter_sum("get_emeter_realtime", {}) - # 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.children)) - return EmeterStatus(emeter_rt) - - @requires_update - async def get_emeter_daily( - self, year: Optional[int] = None, month: Optional[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 - """ - return await self._async_get_emeter_sum( - "get_emeter_daily", {"year": year, "month": month, "kwh": kwh} - ) - - @requires_update - async def get_emeter_monthly( - self, year: Optional[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 await self._async_get_emeter_sum( - "get_emeter_monthly", {"year": year, "kwh": kwh} - ) - - async def _async_get_emeter_sum(self, func: str, kwargs: Dict[str, Any]) -> Dict: - """Retreive emeter stats for a time period from children.""" - self._verify_emeter() - return merge_sums( - [await getattr(plug, func)(**kwargs) for plug in self.children] - ) - - @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() - - @property # type: ignore - @requires_update - def emeter_this_month(self) -> Optional[float]: - """Return this month's energy consumption in kWh.""" - return sum(plug.emeter_this_month for plug in self.children) - - @property # type: ignore - @requires_update - def emeter_today(self) -> Optional[float]: - """Return this month's energy consumption in kWh.""" - return sum(plug.emeter_today for plug in self.children) - - @property # type: ignore - @requires_update - def emeter_realtime(self) -> EmeterStatus: - """Return current energy readings.""" - emeter = merge_sums([plug.emeter_realtime for plug in self.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.children)) - return EmeterStatus(emeter) - - -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._set_sys_info(parent.sys_info) - self._device_type = DeviceType.StripSocket - self.modules = {} - self.protocol = parent.protocol # Must use the same connection as the parent - self.add_module("time", Time(self, "time")) - - async def update(self, update_children: bool = True): - """Query the device to update the data. - - Needed for properties that are decorated with `requires_update`. - """ - await self._modular_update({}) - - def _create_emeter_request( - self, year: Optional[int] = None, month: Optional[int] = None - ): - """Create a request for requesting all emeter statistics at once.""" - if year is None: - year = datetime.now().year - if month is None: - month = datetime.now().month - - req: Dict[str, Any] = {} - - merge(req, self._create_request("emeter", "get_realtime")) - merge(req, self._create_request("emeter", "get_monthstat", {"year": year})) - merge( - req, - self._create_request( - "emeter", "get_daystat", {"month": month, "year": year} - ), - ) - - return req - - def _create_request( - self, target: str, cmd: str, arg: Optional[Dict] = None, child_ids=None - ): - 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: 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 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().replace(microsecond=0) - 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/tapo/__init__.py b/kasa/tapo/__init__.py deleted file mode 100644 index eeb3670c..00000000 --- a/kasa/tapo/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Package for supporting tapo-branded and newer kasa devices.""" -from .tapobulb import TapoBulb -from .tapodevice import TapoDevice -from .tapoplug import TapoPlug - -__all__ = ["TapoDevice", "TapoPlug", "TapoBulb"] diff --git a/kasa/tapo/tapobulb.py b/kasa/tapo/tapobulb.py deleted file mode 100644 index bbaf093d..00000000 --- a/kasa/tapo/tapobulb.py +++ /dev/null @@ -1,261 +0,0 @@ -"""Module for tapo-branded smart bulbs (L5**).""" -from typing import Any, Dict, List, Optional - -from ..exceptions import SmartDeviceException -from ..smartbulb import HSV, ColorTempRange, SmartBulb, SmartBulbPreset -from .tapodevice import TapoDevice - -AVAILABLE_EFFECTS = { - "L1": "Party", - "L2": "Relax", -} - - -class TapoBulb(TapoDevice, SmartBulb): - """Representation of a TP-Link Tapo Bulb. - - Documentation TBD. See :class:`~kasa.smartbulb.SmartBulb` for now. - """ - - @property - def is_color(self) -> bool: - """Whether the bulb supports color changes.""" - # TODO: this makes an assumption that only color bulbs report this - return "hue" in self._info - - @property - def is_dimmable(self) -> bool: - """Whether the bulb supports brightness changes.""" - # TODO: this makes an assumption that only dimmables report this - return "brightness" in self._info - - @property - def is_variable_color_temp(self) -> bool: - """Whether the bulb supports color temperature changes.""" - ct = self._info.get("color_temp_range") - # L900 reports [9000, 9000] even when it doesn't support changing the ct - return ct is not None and ct[0] != ct[1] - - @property - def valid_temperature_range(self) -> ColorTempRange: - """Return the device-specific white temperature range (in Kelvin). - - :return: White temperature range in Kelvin (minimum, maximum) - """ - if not self.is_variable_color_temp: - raise SmartDeviceException("Color temperature not supported") - - ct_range = self._info.get("color_temp_range", [0, 0]) - return ColorTempRange(min=ct_range[0], max=ct_range[1]) - - @property - def has_effects(self) -> bool: - """Return True if the device supports effects.""" - return "dynamic_light_effect_enable" in self._info - - @property - def effect(self) -> Dict: - """Return effect state. - - This follows the format used by SmartLightStrip. - - Example: - {'brightness': 50, - 'custom': 0, - 'enable': 0, - 'id': '', - 'name': ''} - """ - # If no effect is active, dynamic_light_effect_id does not appear in info - current_effect = self._info.get("dynamic_light_effect_id", "") - data = { - "brightness": self.brightness, - "enable": current_effect != "", - "id": current_effect, - "name": AVAILABLE_EFFECTS.get(current_effect, ""), - } - - return data - - @property - def effect_list(self) -> Optional[List[str]]: - """Return built-in effects list. - - Example: - ['Party', 'Relax', ...] - """ - return list(AVAILABLE_EFFECTS.keys()) if self.has_effects else None - - @property - def hsv(self) -> HSV: - """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.") - - h, s, v = ( - self._info.get("hue", 0), - self._info.get("saturation", 0), - self._info.get("brightness", 0), - ) - - return HSV(hue=h, saturation=s, value=v) - - @property - def color_temp(self) -> int: - """Whether the bulb supports color temperature changes.""" - if not self.is_variable_color_temp: - raise SmartDeviceException("Bulb does not support colortemp.") - - return self._info.get("color_temp", -1) - - @property - def brightness(self) -> int: - """Return the current brightness in percentage.""" - if not self.is_dimmable: # pragma: no cover - raise SmartDeviceException("Bulb is not dimmable.") - - return self._info.get("brightness", -1) - - async def set_hsv( - self, - hue: int, - saturation: int, - value: Optional[int] = None, - *, - transition: Optional[int] = 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 self.is_color: - raise SmartDeviceException("Bulb does not support color.") - - if not isinstance(hue, int) or not (0 <= hue <= 360): - raise ValueError(f"Invalid hue value: {hue} (valid range: 0-360)") - - if not isinstance(saturation, int) or 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.protocol.query({"set_device_info": {**request_payload}}) - - async def set_color_temp( - self, temp: int, *, brightness=None, transition: Optional[int] = None - ) -> Dict: - """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. - """ - # TODO: Note, trying to set brightness at the same time - # with color_temp causes error -1008 - 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 {}, was {}".format( - *valid_temperature_range, temp - ) - ) - - return await self.protocol.query({"set_device_info": {"color_temp": temp}}) - - async def set_brightness( - self, brightness: int, *, transition: Optional[int] = None - ) -> Dict: - """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 not self.is_dimmable: # pragma: no cover - raise SmartDeviceException("Bulb is not dimmable.") - - return await self.protocol.query( - {"set_device_info": {"brightness": brightness}} - ) - - # Default state information, should be made to settings - """ - "info": { - "default_states": { - "re_power_type": "always_on", - "type": "last_states", - "state": { - "brightness": 36, - "hue": 0, - "saturation": 0, - "color_temp": 2700, - }, - }, - """ - - async def set_effect( - self, - effect: str, - *, - brightness: Optional[int] = None, - transition: Optional[int] = None, - ) -> None: - """Set an effect on the device.""" - raise NotImplementedError() - # TODO: the code below does to activate the effect but gives no error - return await self.protocol.query( - { - "set_device_info": { - "dynamic_light_effect_enable": 1, - "dynamic_light_effect_id": effect, - } - } - ) - - @property # type: ignore - def state_information(self) -> Dict[str, Any]: - """Return bulb-specific state information.""" - info: Dict[str, Any] = { - # TODO: re-enable after we don't inherit from smartbulb - # **super().state_information - "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 - info["Presets"] = self.presets - - return info - - @property - def presets(self) -> List[SmartBulbPreset]: - """Return a list of available bulb setting presets.""" - return [] diff --git a/kasa/tapo/tapodevice.py b/kasa/tapo/tapodevice.py deleted file mode 100644 index 8edec611..00000000 --- a/kasa/tapo/tapodevice.py +++ /dev/null @@ -1,341 +0,0 @@ -"""Module for a TAPO device.""" -import base64 -import logging -from datetime import datetime, timedelta, timezone -from typing import Any, Dict, List, Optional, Set, cast - -from ..aestransport import AesTransport -from ..deviceconfig import DeviceConfig -from ..emeterstatus import EmeterStatus -from ..exceptions import AuthenticationException, SmartDeviceException -from ..modules import Emeter -from ..protocol import TPLinkProtocol -from ..smartdevice import SmartDevice, WifiNetwork -from ..smartprotocol import SmartProtocol - -_LOGGER = logging.getLogger(__name__) - - -class TapoDevice(SmartDevice): - """Base class to represent a TAPO device.""" - - def __init__( - self, - host: str, - *, - config: Optional[DeviceConfig] = None, - protocol: Optional[TPLinkProtocol] = None, - ) -> None: - _protocol = protocol or SmartProtocol( - transport=AesTransport(config=config or DeviceConfig(host=host)), - ) - super().__init__(host=host, config=config, protocol=_protocol) - self._components_raw: Optional[Dict[str, Any]] = None - self._components: Dict[str, int] - self._state_information: Dict[str, Any] = {} - self._discovery_info: Optional[Dict[str, Any]] = None - self.modules: Dict[str, Any] = {} - - async def update(self, update_children: bool = True): - """Update the device.""" - if self.credentials is None and self.credentials_hash is None: - raise AuthenticationException("Tapo plug requires authentication.") - - if self._components_raw is None: - resp = await self.protocol.query("component_nego") - self._components_raw = resp["component_nego"] - self._components = { - comp["id"]: comp["ver_code"] - for comp in self._components_raw["component_list"] - } - await self._initialize_modules() - - extra_reqs: Dict[str, Any] = {} - if "energy_monitoring" in self._components: - extra_reqs = { - **extra_reqs, - "get_energy_usage": None, - "get_current_power": None, - } - - req = { - "get_device_info": None, - "get_device_usage": None, - "get_device_time": None, - **extra_reqs, - } - - resp = await self.protocol.query(req) - - self._info = resp["get_device_info"] - self._usage = resp["get_device_usage"] - self._time = resp["get_device_time"] - # Emeter is not always available, but we set them still for now. - self._energy = resp.get("get_energy_usage", {}) - self._emeter = resp.get("get_current_power", {}) - - self._last_update = self._data = { - "components": self._components_raw, - "info": self._info, - "usage": self._usage, - "time": self._time, - "energy": self._energy, - "emeter": self._emeter, - } - - _LOGGER.debug("Got an update: %s", self._data) - - async def _initialize_modules(self): - """Initialize modules based on component negotiation response.""" - if "energy_monitoring" in self._components: - self.emeter_type = "emeter" - self.modules["emeter"] = Emeter(self, self.emeter_type) - - @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.""" - return str(self._info.get("model")) - - @property - def alias(self) -> Optional[str]: - """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.""" - td = timedelta(minutes=cast(float, self._time.get("time_diff"))) - if self._time.get("region"): - tz = timezone(td, str(self._time.get("region"))) - else: - # in case the device returns a blank region this will result in the - # tzname being a UTC offset - tz = timezone(td) - return datetime.fromtimestamp( - cast(float, self._time.get("timestamp")), - tz=tz, - ) - - @property - def timezone(self) -> Dict: - """Return the timezone and time_difference.""" - ti = self.time - return {"timezone": ti.tzname()} - - @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")) / 10_000, - "longitude": cast(float, self._info.get("longitude")) / 10_000, - } - return loc - - @property - def rssi(self) -> Optional[int]: - """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) -> Any: - """Return all the internal state data.""" - return self._data - - async def _query_helper( - self, target: str, cmd: str, arg: Optional[Dict] = None, child_ids=None - ) -> Any: - res = await self.protocol.query({cmd: arg}) - - return res - - @property - def state_information(self) -> Dict[str, Any]: - """Return the key state information.""" - return { - "overheated": self._info.get("overheated"), - "signal_level": self._info.get("signal_level"), - "SSID": base64.b64decode(str(self._info.get("ssid"))).decode(), - } - - @property - def features(self) -> Set[str]: - """Return the list of supported features.""" - # TODO: - return set() - - @property - def has_emeter(self) -> bool: - """Return if the device has emeter.""" - return "energy_monitoring" in self._components - - @property - def is_on(self) -> bool: - """Return true if the device is on.""" - return bool(self._info.get("device_on")) - - async def turn_on(self, **kwargs): - """Turn on the device.""" - await self.protocol.query({"set_device_info": {"device_on": True}}) - - async def turn_off(self, **kwargs): - """Turn off the device.""" - await self.protocol.query({"set_device_info": {"device_on": False}}) - - def update_from_discover_info(self, info): - """Update state from info from the discover call.""" - self._discovery_info = info - self._info = info - - async def get_emeter_realtime(self) -> EmeterStatus: - """Retrieve current energy readings.""" - self._verify_emeter() - resp = await self.protocol.query("get_energy_usage") - self._energy = resp["get_energy_usage"] - return self.emeter_realtime - - def _convert_energy_data(self, data, scale) -> Optional[float]: - """Return adjusted emeter information.""" - return data if not data else data * scale - - @property - def emeter_realtime(self) -> EmeterStatus: - """Get the emeter status.""" - return EmeterStatus( - { - "power_mw": self._energy.get("current_power"), - "total": self._convert_energy_data( - self._energy.get("today_energy"), 1 / 1000 - ), - } - ) - - @property - def emeter_this_month(self) -> Optional[float]: - """Get the emeter value for this month.""" - return self._convert_energy_data(self._energy.get("month_energy"), 1 / 1000) - - @property - def emeter_today(self) -> Optional[float]: - """Get the emeter value for today.""" - return self._convert_energy_data(self._energy.get("today_energy"), 1 / 1000) - - async def wifi_scan(self) -> List[WifiNetwork]: - """Scan for available wifi networks.""" - - def _net_for_scan_info(res): - 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"], - ) - - async def _query_networks(networks=None, start_index=0): - _LOGGER.debug("Querying networks using start_index=%s", start_index) - if networks is None: - networks = [] - - resp = await self.protocol.query( - {"get_wireless_scan_info": {"start_index": start_index}} - ) - network_list = [ - _net_for_scan_info(net) - for net in resp["get_wireless_scan_info"]["ap_list"] - ] - networks.extend(network_list) - - if resp["get_wireless_scan_info"].get("sum", 0) > start_index + 10: - return await _query_networks(networks, start_index=start_index + 10) - - return networks - - return await _query_networks() - - async def wifi_join(self, ssid: str, password: str, keytype: str = "wpa2_psk"): - """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 AuthenticationException("Device requires authentication.") - - 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["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 SmartDeviceException as ex: - if ex.error_code: # Re-raise on device-reported errors - raise - - _LOGGER.debug("Received an expected for wifi join, but this is expected") - - async def update_credentials(self, username: str, password: str): - """Update device credentials. - - This will replace the existing authentication credentials on the device. - """ - t = self.internal_state["time"] - payload = { - "account": { - "username": base64.b64encode(username.encode()).decode(), - "password": base64.b64encode(password.encode()).decode(), - }, - "time": t, - } - return await self.protocol.query({"set_qs_info": payload}) diff --git a/kasa/tapo/tapoplug.py b/kasa/tapo/tapoplug.py deleted file mode 100644 index bb20f5cc..00000000 --- a/kasa/tapo/tapoplug.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Module for a TAPO Plug.""" -import logging -from datetime import datetime, timedelta -from typing import Any, Dict, Optional, cast - -from ..deviceconfig import DeviceConfig -from ..protocol import TPLinkProtocol -from ..smartdevice import DeviceType -from .tapodevice import TapoDevice - -_LOGGER = logging.getLogger(__name__) - - -class TapoPlug(TapoDevice): - """Class to represent a TAPO Plug.""" - - def __init__( - self, - host: str, - *, - config: Optional[DeviceConfig] = None, - protocol: Optional[TPLinkProtocol] = None, - ) -> None: - super().__init__(host=host, config=config, protocol=protocol) - self._device_type = DeviceType.Plug - - @property - def state_information(self) -> Dict[str, Any]: - """Return the key state information.""" - return { - **super().state_information, - **{ - "On since": self.on_since, - "auto_off_status": self._info.get("auto_off_status"), - "auto_off_remain_time": self._info.get("auto_off_remain_time"), - }, - } - - @property - def on_since(self) -> Optional[datetime]: - """Return the time that the device was turned on or None if turned off.""" - if not self._info.get("device_on"): - return None - on_time = cast(float, self._info.get("on_time")) - return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time) diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py deleted file mode 100644 index dc244402..00000000 --- a/kasa/tests/conftest.py +++ /dev/null @@ -1,577 +0,0 @@ -import asyncio -import glob -import json -import os -from dataclasses import dataclass -from json import dumps as json_dumps -from os.path import basename -from pathlib import Path, PurePath -from typing import Dict, Optional, Set -from unittest.mock import MagicMock - -import pytest # type: ignore # see https://github.com/pytest-dev/pytest/issues/3342 - -from kasa import ( - Credentials, - Discover, - SmartBulb, - SmartDimmer, - SmartLightStrip, - SmartPlug, - SmartStrip, - TPLinkSmartHomeProtocol, -) -from kasa.tapo import TapoBulb, TapoDevice, TapoPlug - -from .newfakes import FakeSmartProtocol, FakeTransportProtocol - -SUPPORTED_IOT_DEVICES = [ - (device, "IOT") - for device in glob.glob( - os.path.dirname(os.path.abspath(__file__)) + "/fixtures/*.json" - ) -] - -SUPPORTED_SMART_DEVICES = [ - (device, "SMART") - for device in glob.glob( - os.path.dirname(os.path.abspath(__file__)) + "/fixtures/smart/*.json" - ) -] - - -SUPPORTED_DEVICES = SUPPORTED_IOT_DEVICES + SUPPORTED_SMART_DEVICES - -# Tapo bulbs -BULBS_SMART_VARIABLE_TEMP = {"L530E"} -BULBS_SMART_LIGHT_STRIP = {"L900-5", "L900-10", "L920-5"} -BULBS_SMART_COLOR = {"L530E", *BULBS_SMART_LIGHT_STRIP} -BULBS_SMART_DIMMABLE = {"KS225"} -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", "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"} -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", - "HS200", - "HS210", - "EP10", - "KP100", - "KP105", - "KP115", - "KP125", - "KP401", - "KS200M", -} -PLUGS_SMART = {"P110", "KP125M", "EP25", "KS205", "P125M"} -PLUGS = { - *PLUGS_IOT, - *PLUGS_SMART, -} -STRIPS_IOT = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40"} -STRIPS_SMART: Set[str] = set() -STRIPS = {*STRIPS_IOT, *STRIPS_SMART} - -DIMMERS_IOT = {"ES20M", "HS220", "KS220M", "KS230", "KP405"} -DIMMERS_SMART: Set[str] = set() -DIMMERS = { - *DIMMERS_IOT, - *DIMMERS_SMART, -} - -WITH_EMETER_IOT = {"HS110", "HS300", "KP115", "KP125", *BULBS_IOT} -WITH_EMETER_SMART = {"P110", "KP125M", "EP25"} -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) -ALL_DEVICES_SMART = ( - BULBS_SMART.union(PLUGS_SMART).union(STRIPS_SMART).union(DIMMERS_SMART) -) -ALL_DEVICES = ALL_DEVICES_IOT.union(ALL_DEVICES_SMART) - -IP_MODEL_CACHE: Dict[str, str] = {} - - -def _make_unsupported(device_family, encrypt_type): - return { - "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": False, - "encrypt_type": encrypt_type, - "http_port": 80, - "lv": 2, - }, - }, - "error_code": 0, - } - - -UNSUPPORTED_DEVICES = { - "unknown_device_family": _make_unsupported("SMART.TAPOXMASTREE", "AES"), - "wrong_encryption_iot": _make_unsupported("IOT.SMARTPLUGSWITCH", "AES"), - "wrong_encryption_smart": _make_unsupported("SMART.TAPOBULB", "IOT"), - "unknown_encryption": _make_unsupported("IOT.SMARTPLUGSWITCH", "FOO"), -} - - -def idgenerator(paramtuple): - try: - return basename(paramtuple[0]) + ( - "" if paramtuple[1] == "IOT" else "-" + paramtuple[1] - ) - except: # TODO: HACK as idgenerator is now used by default # noqa: E722 - return None - - -def filter_model(desc, model_filter, protocol_filter=None): - if protocol_filter is None: - protocol_filter = {"IOT", "SMART"} - filtered = list() - for file, protocol in SUPPORTED_DEVICES: - if protocol in protocol_filter: - file_model_region = basename(file).split("_")[0] - file_model = file_model_region.split("(")[0] - for model in model_filter: - if model == file_model: - filtered.append((file, protocol)) - - filtered_basenames = [basename(f) + "-" + p for f, p in filtered] - print(f"# {desc}") - for file in filtered_basenames: - print(f"\t{file}") - return filtered - - -def parametrize(desc, devices, protocol_filter=None, ids=None): - if ids is None: - ids = idgenerator - return pytest.mark.parametrize( - "dev", filter_model(desc, devices, protocol_filter), indirect=True, ids=ids - ) - - -has_emeter = parametrize("has emeter", WITH_EMETER, protocol_filter={"SMART", "IOT"}) -no_emeter = parametrize( - "no emeter", ALL_DEVICES - WITH_EMETER, protocol_filter={"SMART", "IOT"} -) -has_emeter_iot = parametrize("has emeter iot", WITH_EMETER_IOT, protocol_filter={"IOT"}) -no_emeter_iot = parametrize( - "no emeter iot", ALL_DEVICES_IOT - WITH_EMETER_IOT, protocol_filter={"IOT"} -) - -bulb = parametrize("bulbs", BULBS, protocol_filter={"SMART", "IOT"}) -plug = parametrize("plugs", PLUGS, protocol_filter={"IOT"}) -strip = parametrize("strips", STRIPS, protocol_filter={"IOT"}) -dimmer = parametrize("dimmers", DIMMERS, protocol_filter={"IOT"}) -lightstrip = parametrize("lightstrips", LIGHT_STRIPS, protocol_filter={"IOT"}) - -# bulb types -dimmable = parametrize("dimmable", DIMMABLE, protocol_filter={"IOT"}) -non_dimmable = parametrize("non-dimmable", BULBS - DIMMABLE, protocol_filter={"IOT"}) -variable_temp = parametrize( - "variable color temp", BULBS_VARIABLE_TEMP, protocol_filter={"SMART", "IOT"} -) -non_variable_temp = parametrize( - "non-variable color temp", - BULBS - BULBS_VARIABLE_TEMP, - protocol_filter={"SMART", "IOT"}, -) -color_bulb = parametrize("color bulbs", BULBS_COLOR, protocol_filter={"SMART", "IOT"}) -non_color_bulb = parametrize( - "non-color bulbs", BULBS - BULBS_COLOR, protocol_filter={"SMART", "IOT"} -) - -color_bulb_iot = parametrize( - "color bulbs iot", BULBS_IOT_COLOR, protocol_filter={"IOT"} -) -variable_temp_iot = parametrize( - "variable color temp iot", BULBS_IOT_VARIABLE_TEMP, protocol_filter={"IOT"} -) -bulb_iot = parametrize("bulb devices iot", BULBS_IOT, protocol_filter={"IOT"}) - -plug_smart = parametrize("plug devices smart", PLUGS_SMART, protocol_filter={"SMART"}) -bulb_smart = parametrize("bulb devices smart", BULBS_SMART, protocol_filter={"SMART"}) -device_smart = parametrize( - "devices smart", ALL_DEVICES_SMART, protocol_filter={"SMART"} -) -device_iot = parametrize("devices iot", ALL_DEVICES_IOT, protocol_filter={"IOT"}) - - -def get_fixture_data(): - """Return raw discovery file contents as JSON. Used for discovery tests.""" - fixture_data = {} - for file, protocol in SUPPORTED_DEVICES: - p = Path(file) - if not p.is_absolute(): - folder = Path(__file__).parent / "fixtures" - if protocol == "SMART": - folder = folder / "smart" - p = folder / file - - with open(p) as f: - fixture_data[basename(p)] = json.load(f) - return fixture_data - - -FIXTURE_DATA = get_fixture_data() - - -def filter_fixtures(desc, root_filter): - filtered = {} - for key, val in FIXTURE_DATA.items(): - if root_filter in val: - filtered[key] = val - - print(f"# {desc}") - for key in filtered: - print(f"\t{key}") - return filtered - - -def parametrize_discovery(desc, root_key): - filtered_fixtures = filter_fixtures(desc, root_key) - return pytest.mark.parametrize( - "all_fixture_data", - filtered_fixtures.values(), - indirect=True, - ids=filtered_fixtures.keys(), - ) - - -new_discovery = parametrize_discovery("new discovery", "discovery_result") - - -def check_categories(): - """Check that every fixture file is categorized.""" - categorized_fixtures = set( - dimmer.args[1] - + strip.args[1] - + plug.args[1] - + bulb.args[1] - + lightstrip.args[1] - + plug_smart.args[1] - + bulb_smart.args[1] - ) - diff = set(SUPPORTED_DEVICES) - set(categorized_fixtures) - if diff: - for file, protocol in diff: - print( - f"No category for file {file} protocol {protocol}, add to the corresponding set (BULBS, PLUGS, ..)" - ) - raise Exception(f"Missing category for {diff}") - - -check_categories() - -# 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() - - -def device_for_file(model, protocol): - if protocol == "SMART": - for d in PLUGS_SMART: - if d in model: - return TapoPlug - for d in BULBS_SMART: - if d in model: - return TapoBulb - else: - for d in STRIPS_IOT: - if d in model: - return SmartStrip - - for d in PLUGS_IOT: - if d in model: - return SmartPlug - - # 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 SmartLightStrip - - for d in BULBS_IOT: - if d in model: - return SmartBulb - - for d in DIMMERS_IOT: - if d in model: - return SmartDimmer - - raise Exception("Unable to find type for %s", model) - - -async def _update_and_close(d): - await d.update() - await d.protocol.close() - return d - - -async def _discover_update_and_close(ip, username, password): - 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_file(file, protocol): - # if the wanted file is not an absolute path, prepend the fixtures directory - p = Path(file) - if not p.is_absolute(): - folder = Path(__file__).parent / "fixtures" - if protocol == "SMART": - folder = folder / "smart" - p = folder / file - - def load_file(): - with open(p) as f: - return json.load(f) - - loop = asyncio.get_running_loop() - sysinfo = await loop.run_in_executor(None, load_file) - - model = basename(file) - d = device_for_file(model, protocol)(host="127.0.0.123") - if protocol == "SMART": - d.protocol = FakeSmartProtocol(sysinfo) - else: - d.protocol = FakeTransportProtocol(sysinfo) - await _update_and_close(d) - return d - - -@pytest.fixture(params=SUPPORTED_DEVICES, ids=idgenerator) -async 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, protocol = request.param - - ip = request.config.getoption("--ip") - username = request.config.getoption("--username") - password = request.config.getoption("--password") - if ip: - model = IP_MODEL_CACHE.get(ip) - d = None - if not model: - d = await _discover_update_and_close(ip, username, password) - IP_MODEL_CACHE[ip] = model = d.model - if model not in file: - pytest.skip(f"skipping file {file}") - return d if d else await _discover_update_and_close(ip, username, password) - - return await get_device_for_file(file, protocol) - - -@pytest.fixture -def discovery_mock(all_fixture_data, mocker): - @dataclass - class _DiscoveryMock: - ip: str - default_port: int - discovery_port: int - discovery_data: dict - query_data: dict - device_type: str - encrypt_type: str - login_version: Optional[int] = None - port_override: Optional[int] = None - - if "discovery_result" in all_fixture_data: - discovery_data = {"result": all_fixture_data["discovery_result"]} - device_type = all_fixture_data["discovery_result"]["device_type"] - encrypt_type = all_fixture_data["discovery_result"]["mgt_encrypt_schm"][ - "encrypt_type" - ] - login_version = all_fixture_data["discovery_result"]["mgt_encrypt_schm"].get( - "lv" - ) - datagram = ( - b"\x02\x00\x00\x01\x01[\x00\x00\x00\x00\x00\x00W\xcev\xf8" - + json_dumps(discovery_data).encode() - ) - dm = _DiscoveryMock( - "127.0.0.123", - 80, - 20002, - discovery_data, - all_fixture_data, - device_type, - encrypt_type, - login_version, - ) - else: - sys_info = all_fixture_data["system"]["get_sysinfo"] - discovery_data = {"system": {"get_sysinfo": sys_info}} - device_type = sys_info.get("mic_type") or sys_info.get("type") - encrypt_type = "XOR" - login_version = None - datagram = TPLinkSmartHomeProtocol.encrypt(json_dumps(discovery_data))[4:] - dm = _DiscoveryMock( - "127.0.0.123", - 9999, - 9999, - discovery_data, - all_fixture_data, - device_type, - encrypt_type, - login_version, - ) - - def mock_discover(self): - port = ( - dm.port_override - if dm.port_override and dm.discovery_port != 20002 - else dm.discovery_port - ) - self.datagram_received( - datagram, - (dm.ip, port), - ) - - mocker.patch("kasa.discover._DiscoverProtocol.do_discover", mock_discover) - mocker.patch( - "socket.getaddrinfo", - side_effect=lambda *_, **__: [(None, None, None, None, (dm.ip, 0))], - ) - - if "component_nego" in dm.query_data: - proto = FakeSmartProtocol(dm.query_data) - else: - proto = FakeTransportProtocol(dm.query_data) - - async def _query(request, retry_count: int = 3): - return await proto.query(request) - - mocker.patch("kasa.IotProtocol.query", side_effect=_query) - mocker.patch("kasa.SmartProtocol.query", side_effect=_query) - mocker.patch("kasa.TPLinkSmartHomeProtocol.query", side_effect=_query) - - yield dm - - -@pytest.fixture -def discovery_data(all_fixture_data): - """Return raw discovery file contents as JSON. Used for discovery tests.""" - if "discovery_result" in all_fixture_data: - return {"result": all_fixture_data["discovery_result"]} - else: - return {"system": {"get_sysinfo": all_fixture_data["system"]["get_sysinfo"]}} - - -@pytest.fixture(params=FIXTURE_DATA.values(), ids=FIXTURE_DATA.keys(), scope="session") -def all_fixture_data(request): - """Return raw fixture file contents as JSON. Used for discovery tests.""" - fixture_data = request.param - return fixture_data - - -@pytest.fixture(params=UNSUPPORTED_DEVICES.values(), ids=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" - - 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) - - yield discovery_data - - -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.") - else: - print("Running against ip %s" % 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) - - -# 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/HS105(US)_1.0_mocked.json b/kasa/tests/fixtures/HS105(US)_1.0_mocked.json deleted file mode 100644 index cf2aa5f4..00000000 --- a/kasa/tests/fixtures/HS105(US)_1.0_mocked.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_1.2.5.json b/kasa/tests/fixtures/HS110(EU)_1.0_1.2.5.json deleted file mode 100644 index 1a8ced8f..00000000 --- a/kasa/tests/fixtures/HS110(EU)_1.0_1.2.5.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_mocked.json b/kasa/tests/fixtures/HS110(EU)_2.0_mocked.json deleted file mode 100644 index 9fa57d3b..00000000 --- a/kasa/tests/fixtures/HS110(EU)_2.0_mocked.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_mocked.json b/kasa/tests/fixtures/HS110(US)_1.0_mocked.json deleted file mode 100644 index 562b28a3..00000000 --- a/kasa/tests/fixtures/HS110(US)_1.0_mocked.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_mocked.json b/kasa/tests/fixtures/HS200(US)_1.0_mocked.json deleted file mode 100644 index 5e9e8b8e..00000000 --- a/kasa/tests/fixtures/HS200(US)_1.0_mocked.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/HS220(US)_1.0_mocked.json b/kasa/tests/fixtures/HS220(US)_1.0_mocked.json deleted file mode 100644 index 15afc613..00000000 --- a/kasa/tests/fixtures/HS220(US)_1.0_mocked.json +++ /dev/null @@ -1,75 +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": { - "get_dimmer_parameters": { - "bulb_type": 1, - "err_code": 0, - "fadeOffTime": 3000, - "fadeOnTime": 3000, - "gentleOffTime": 510000, - "gentleOnTime": 3000, - "minThreshold": 0, - "rampRate": 30 - } - }, - "smartlife.iot.smartbulb.lightingservice": { - "err_code": -1, - "err_msg": "module not support" - }, - "system": { - "get_sysinfo": { - "active_mode": "count_down", - "alias": "Mock hs220", - "brightness": 50, - "dev_name": "Smart Wi-Fi Dimmer", - "deviceId": "98E16F2D5ED204F3094CF472260237133DC0D547", - "err_code": 0, - "feature": "TIM", - "hwId": "231004CCCDB6C0B8FC7A3260C3470257", - "hw_ver": "1.0", - "icon_hash": "", - "INVALIDlatitude": 11.6210, - "latitude_i": 11.6210, - "led_off": 0, - "INVALIDlongitude": 42.2074, - "longitude_i": 42.2074, - "mac": "50:c7:bf:af:75:5d", - "mic_type": "IOT.SMARTPLUGSWITCH", - "model": "HS220(US)", - "oemId": "8FBD0F3CCF7E82836DC7996C524EF772", - "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": -65, - "sw_ver": "1.5.7 Build 180912 Rel.104837", - "type": "IOT.SMARTPLUGSWITCH", - "updating": 0 - } - } -} diff --git a/kasa/tests/fixtures/LB100(US)_1.0_mocked.json b/kasa/tests/fixtures/LB100(US)_1.0_mocked.json deleted file mode 100644 index 8f5026e2..00000000 --- a/kasa/tests/fixtures/LB100(US)_1.0_mocked.json +++ /dev/null @@ -1,103 +0,0 @@ -{ - "emeter": { - "err_code": -1, - "err_msg": "module not support" - }, - "smartlife.iot.common.emeter": { - "get_realtime": { - "err_code": 0, - "power_mw": 10800 - } - }, - "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, - "mode": "normal", - "saturation": 0 - }, - "err_code": 0, - "on_off": 0 - } - }, - "system": { - "get_sysinfo": { - "active_mode": "none", - "alias": "Mock lb100", - "ctrl_protocols": { - "name": "Linkie", - "version": "1.0" - }, - "description": "Smart Wi-Fi LED Bulb with Dimmable Light", - "dev_state": "normal", - "deviceId": "15BD5A6C4B729A7C0D4D46ADDFA7E2600793C56A", - "disco_ver": "1.0", - "err_code": 0, - "heapsize": 302452, - "hwId": "1B0DF0A2EFE6251DBE726D1D2167C78F", - "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, - "color_temp": 2700, - "hue": 0, - "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_type": "IOT.SMARTBULB", - "model": "LB100(US)", - "oemId": "C9CF655C9A5AA101E66EBA5B382E40CC", - "on_time": 0, - "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": -65, - "sw_ver": "1.4.3 Build 170504 Rel.144921" - } - } -} diff --git a/kasa/tests/fixtures/LB130(US)_1.0_mocked.json b/kasa/tests/fixtures/LB130(US)_1.0_mocked.json deleted file mode 100644 index dcd441ce..00000000 --- a/kasa/tests/fixtures/LB130(US)_1.0_mocked.json +++ /dev/null @@ -1,104 +0,0 @@ -{ - "emeter": { - "err_code": -1, - "err_msg": "module not support" - }, - "smartlife.iot.common.emeter": { - "get_realtime": { - "err_code": 0, - "power_mw": 10800 - } - }, - "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, - "mode": "normal", - "saturation": 0 - }, - "err_code": 0, - "on_off": 0 - } - }, - "system": { - "get_sysinfo": { - "active_mode": "none", - "alias": "Mock lb130", - "ctrl_protocols": { - "name": "Linkie", - "version": "1.0" - }, - "description": "Smart Wi-Fi LED Bulb with Color Changing", - "dev_state": "normal", - "deviceId": "50BE9E7B6F26CA75D495C13EAA459C491768F143", - "disco_ver": "1.0", - "err_code": 0, - "heapsize": 302452, - "hwId": "C8AD962B53417C2845CC10CE25C00BB1", - "hw_ver": "1.0", - "is_color": 1, - "is_dimmable": 1, - "is_factory": false, - "is_variable_color_temp": 1, - "latitude": 76.8649, - "latitude_i": 76.8649, - "light_state": { - "dft_on_state": { - "brightness": 100, - "color_temp": 2700, - "hue": 0, - "mode": "normal", - "saturation": 0 - }, - "err_code": 0, - "on_off": 0 - }, - "longitude": -40.7284, - "longitude_i": -40.7284, - "INVALIDmac": "50:c7:bf:ac:f6:19", - "mic_mac": "50C7BFACF619", - "mic_type": "IOT.SMARTBULB", - "model": "LB130(US)", - "oemId": "CF78964560AAB75A43F15D2E468B63EF", - "on_time": 0, - "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": -65, - "sw_ver": "1.6.0 Build 170703 Rel.141938" - } - } -} diff --git a/kasa/tests/test_aestransport.py b/kasa/tests/test_aestransport.py deleted file mode 100644 index faf47a75..00000000 --- a/kasa/tests/test_aestransport.py +++ /dev/null @@ -1,212 +0,0 @@ -import base64 -import json -import time -from contextlib import nullcontext as does_not_raise -from json import dumps as json_dumps -from json import loads as json_loads - -import httpx -import pytest -from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import padding as asymmetric_padding - -from ..aestransport import AesEncyptionSession, AesTransport -from ..credentials import Credentials -from ..deviceconfig import DeviceConfig -from ..exceptions import ( - SMART_RETRYABLE_ERRORS, - SMART_TIMEOUT_ERRORS, - SmartDeviceException, - SmartErrorCode, -) - -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(SmartDeviceException)), - (200, -1, 0, pytest.raises(SmartDeviceException)), - ], - 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(httpx.AsyncClient, "post", side_effect=mock_aes_device.post) - - transport = AesTransport( - config=DeviceConfig(host, credentials=Credentials("foo", "bar")) - ) - - assert transport._encryption_session is None - assert transport._handshake_done is False - with expectation: - await transport.perform_handshake() - assert transport._encryption_session is not None - assert transport._handshake_done is True - - -@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(httpx.AsyncClient, "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 - - assert transport._login_token is None - with expectation: - await transport.perform_login() - assert transport._login_token == mock_aes_device.token - - -@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(httpx.AsyncClient, "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._login_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 - - -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(httpx.AsyncClient, "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._login_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(SmartDeviceException): - await transport.send(json_dumps(request)) - - -class MockAesDevice: - class _mock_response: - def __init__(self, status_code, json: dict): - self.status_code = status_code - self._json = json - - def json(self): - return self._json - - encryption_session = AesEncyptionSession(KEY_IV[:16], KEY_IV[16:]) - token = "test_token" # noqa - - def __init__(self, host, status_code=200, error_code=0, inner_error_code=0): - self.host = host - self.status_code = status_code - self.error_code = error_code - self.inner_error_code = inner_error_code - - async def post(self, url, params=None, json=None, *_, **__): - return await self._post(url, json) - - async def _post(self, url, json): - 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 == f"http://{self.host}/app?token={self.token}" - return await self._return_send_response(url, json) - - async def _return_handshake_response(self, url, json): - 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, json): - 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) - decrypted_response_dict = decrypted_response.json() - encrypted_response = self.encryption_session.encrypt( - json_dumps(decrypted_response_dict).encode() - ) - result = { - "result": {"response": encrypted_response.decode()}, - "error_code": self.error_code, - } - return self._mock_response(self.status_code, result) - - async def _return_login_response(self, url, json): - result = {"result": {"token": self.token}, "error_code": self.inner_error_code} - return self._mock_response(self.status_code, result) - - async def _return_send_response(self, url, json): - result = {"result": {"method": None}, "error_code": self.inner_error_code} - return self._mock_response(self.status_code, result) diff --git a/kasa/tests/test_bulb.py b/kasa/tests/test_bulb.py deleted file mode 100644 index 0676022b..00000000 --- a/kasa/tests/test_bulb.py +++ /dev/null @@ -1,318 +0,0 @@ -import pytest - -from kasa import DeviceType, SmartBulb, SmartBulbPreset, SmartDeviceException - -from .conftest import ( - bulb, - bulb_iot, - color_bulb, - color_bulb_iot, - dimmable, - handle_turn_on, - non_color_bulb, - non_dimmable, - non_variable_temp, - turn_on, - variable_temp, - variable_temp_iot, -) -from .newfakes import BULB_SCHEMA, LIGHT_STATE_SCHEMA - - -@bulb -async def test_bulb_sysinfo(dev: SmartBulb): - 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: SmartBulb): - 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_iot -async def test_light_state_without_update(dev: SmartBulb, monkeypatch): - with pytest.raises(SmartDeviceException): - monkeypatch.setitem( - dev._last_update["system"]["get_sysinfo"], "light_state", None - ) - print(dev.light_state) - - -@bulb_iot -async def test_get_light_state(dev: SmartBulb): - LIGHT_STATE_SCHEMA(await dev.get_light_state()) - - -@color_bulb -@turn_on -async def test_hsv(dev: SmartBulb, turn_on): - await handle_turn_on(dev, turn_on) - assert dev.is_color - - hue, saturation, brightness = dev.hsv - assert 0 <= hue <= 360 - assert 0 <= saturation <= 100 - assert 0 <= brightness <= 100 - - await dev.set_hsv(hue=1, saturation=1, value=1) - - await dev.update() - hue, saturation, brightness = dev.hsv - assert hue == 1 - assert saturation == 1 - assert brightness == 1 - - -@color_bulb_iot -async def test_set_hsv_transition(dev: SmartBulb, 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: SmartBulb, 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: SmartBulb): - assert "HSV" in dev.state_information - assert dev.state_information["HSV"] == dev.hsv - - -@non_color_bulb -async def test_hsv_on_non_color(dev: SmartBulb): - 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: SmartBulb): - 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: SmartBulb, turn_on): - await handle_turn_on(dev, turn_on) - await dev.set_color_temp(2700) - await dev.update() - assert dev.color_temp == 2700 - - -@variable_temp_iot -async def test_set_color_temp_transition(dev: SmartBulb, 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_iot -async def test_unknown_temp_range(dev: SmartBulb, monkeypatch, caplog): - monkeypatch.setitem(dev._sys_info, "model", "unknown bulb") - - assert dev.valid_temperature_range == (2700, 5000) - assert "Unknown color temperature range, fallback to 2700-5000" in caplog.text - - -@variable_temp -async def test_out_of_range_temperature(dev: SmartBulb): - 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: SmartBulb): - with pytest.raises(SmartDeviceException): - await dev.set_color_temp(2700) - - with pytest.raises(SmartDeviceException): - print(dev.valid_temperature_range) - - with pytest.raises(SmartDeviceException): - print(dev.color_temp) - - -@dimmable -@turn_on -async def test_dimmable_brightness(dev: SmartBulb, turn_on): - await handle_turn_on(dev, turn_on) - assert dev.is_dimmable - - await dev.set_brightness(50) - await dev.update() - assert dev.brightness == 50 - - await dev.set_brightness(10) - await dev.update() - assert dev.brightness == 10 - - with pytest.raises(ValueError): - await dev.set_brightness("foo") - - -@bulb_iot -async def test_turn_on_transition(dev: SmartBulb, 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_iot -async def test_dimmable_brightness_transition(dev: SmartBulb, 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: SmartBulb): - 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: SmartBulb): - assert not dev.is_dimmable - - with pytest.raises(SmartDeviceException): - assert dev.brightness == 0 - with pytest.raises(SmartDeviceException): - await dev.set_brightness(100) - - -@bulb_iot -async def test_ignore_default_not_set_without_color_mode_change_turn_on( - dev: SmartBulb, mocker -): - query_helper = mocker.patch("kasa.SmartBulb._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: SmartBulb): - presets = dev.presets - assert len(presets) == len(dev.sys_info["preferred_state"]) - - for preset, raw in zip(presets, dev.sys_info["preferred_state"]): - assert preset.index == raw["index"] - assert preset.hue == raw["hue"] - assert preset.brightness == raw["brightness"] - assert preset.saturation == raw["saturation"] - assert preset.color_temp == raw["color_temp"] - - -@bulb_iot -async def test_modify_preset(dev: SmartBulb, mocker): - """Verify that modifying preset calls the and exceptions are raised properly.""" - if not dev.presets: - pytest.skip("Some strips do not support presets") - - data = { - "index": 0, - "brightness": 10, - "hue": 0, - "saturation": 0, - "color_temp": 0, - } - preset = SmartBulbPreset(**data) - - assert preset.index == 0 - assert preset.brightness == 10 - assert preset.hue == 0 - assert preset.saturation == 0 - assert preset.color_temp == 0 - - await dev.save_preset(preset) - assert dev.presets[0].brightness == 10 - - with pytest.raises(SmartDeviceException): - await dev.save_preset( - SmartBulbPreset(index=5, hue=0, brightness=0, saturation=0, color_temp=0) - ) - - -@bulb_iot -@pytest.mark.parametrize( - ("preset", "payload"), - [ - ( - SmartBulbPreset(index=0, hue=0, brightness=1, saturation=0), - {"index": 0, "hue": 0, "brightness": 1, "saturation": 0}, - ), - ( - SmartBulbPreset(index=0, brightness=1, id="testid", mode=2, custom=0), - {"index": 0, "brightness": 1, "id": "testid", "mode": 2, "custom": 0}, - ), - ], -) -async def test_modify_preset_payloads(dev: SmartBulb, preset, payload, mocker): - """Test that modify preset payloads ignore none values.""" - if not dev.presets: - pytest.skip("Some strips do not support presets") - - query_helper = mocker.patch("kasa.SmartBulb._query_helper") - await dev.save_preset(preset) - query_helper.assert_called_with(dev.LIGHT_SERVICE, "set_preferred_state", payload) diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py deleted file mode 100644 index b1db15e1..00000000 --- a/kasa/tests/test_cli.py +++ /dev/null @@ -1,487 +0,0 @@ -import json -import re - -import asyncclick as click -import pytest -from asyncclick.testing import CliRunner - -from kasa import ( - AuthenticationException, - Credentials, - EmeterStatus, - SmartDevice, - SmartDeviceException, - TPLinkSmartHomeProtocol, - UnsupportedDeviceException, -) -from kasa.cli import ( - TYPE_TO_CLASS, - alias, - brightness, - cli, - emeter, - raw_command, - state, - sysinfo, - toggle, - update_credentials, - wifi, -) -from kasa.discover import Discover, DiscoveryResult -from kasa.smartprotocol import SmartProtocol - -from .conftest import device_iot, device_smart, handle_turn_on, new_discovery, turn_on - - -@device_iot -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) - await dev.update() - - 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, mocker): - await handle_turn_on(dev, turn_on) - runner = CliRunner() - await runner.invoke(toggle, obj=dev) - - if turn_on: - assert not dev.is_on - else: - assert dev.is_on - - -@device_iot -async def test_alias(dev): - runner = CliRunner() - - 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 - - res = await runner.invoke(alias, obj=dev) - assert f"Alias: {new_alias}" in res.output - - await dev.set_alias(old_alias) - - -async def test_raw_command(dev): - runner = CliRunner() - from kasa.tapo import TapoDevice - - if isinstance(dev, TapoDevice): - params = ["na", "get_device_info"] - else: - params = ["system", "get_sysinfo"] - res = await runner.invoke(raw_command, params, obj=dev) - - 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 - - -@device_smart -async def test_wifi_scan(dev): - runner = CliRunner() - res = await runner.invoke(wifi, ["scan"], obj=dev) - - assert res.exit_code == 0 - assert re.search(r"Found \d wifi networks!", res.output) - - -@device_smart -async def test_wifi_join(dev): - runner = CliRunner() - res = await runner.invoke( - wifi, - ["join", "FOOBAR", "--keytype", "wpa_psk", "--password", "foobar"], - obj=dev, - ) - - 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 = CliRunner() - 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, AuthenticationException) - - -@device_smart -async def test_wifi_join_exception(dev, mocker): - runner = CliRunner() - mocker.patch.object( - dev.protocol, "query", side_effect=SmartDeviceException(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, SmartDeviceException) - - -@device_smart -async def test_update_credentials(dev): - runner = CliRunner() - 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_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 - - if not dev.is_strip: - res = await runner.invoke(emeter, ["--index", "0"], obj=dev) - assert "Index and name are only for power strips!" in res.output - res = await runner.invoke(emeter, ["--name", "mock"], obj=dev) - assert "Index and name are only for power strips!" in res.output - - if dev.is_strip and len(dev.children) > 0: - realtime_emeter = mocker.patch.object(dev.children[0], "get_emeter_realtime") - realtime_emeter.return_value = EmeterStatus({"voltage_mv": 122066}) - - res = await runner.invoke(emeter, ["--index", "0"], obj=dev) - assert "Voltage: 122.066 V" in res.output - realtime_emeter.assert_called() - assert realtime_emeter.call_count == 1 - - res = await runner.invoke(emeter, ["--name", dev.children[0].alias], obj=dev) - assert "Voltage: 122.066 V" in res.output - assert realtime_emeter.call_count == 2 - - monthly = mocker.patch.object(dev, "get_emeter_monthly") - monthly.return_value = {1: 1234} - res = await runner.invoke(emeter, ["--year", "1900"], obj=dev) - assert "For year" in res.output - assert "1, 1234" in res.output - monthly.assert_called_with(year=1900) - - daily = mocker.patch.object(dev, "get_emeter_daily") - daily.return_value = {1: 1234} - res = await runner.invoke(emeter, ["--month", "1900-12"], obj=dev) - assert "For month" in res.output - assert "1, 1234" in res.output - daily.assert_called_with(year=1900, month=12) - - -@device_iot -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 - - -@device_iot -async def test_json_output(dev: SmartDevice, mocker): - """Test that the json output produces correct output.""" - mocker.patch("kasa.Discover.discover", return_value=[dev]) - runner = CliRunner() - res = await runner.invoke(cli, ["--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): - """Test credentials are passed correctly from cli to device.""" - # Patch state to echo username and password - pass_dev = click.make_pass_decorator(SmartDevice) - - @pass_dev - async def _state(dev: SmartDevice): - if dev.credentials: - click.echo( - f"Username:{dev.credentials.username} Password:{dev.credentials.password}" - ) - - mocker.patch("kasa.cli.state", new=_state) - - mocker.patch("kasa.IotProtocol.query", return_value=discovery_mock.query_data) - mocker.patch("kasa.SmartProtocol.query", return_value=discovery_mock.query_data) - - dr = DiscoveryResult(**discovery_mock.discovery_data["result"]) - runner = CliRunner() - 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, - ], - ) - assert res.exit_code == 0 - - assert "Username:foo Password:bar\n" in res.output - - -@device_iot -async def test_without_device_type(dev, mocker): - """Test connecting without the device type.""" - runner = CliRunner() - mocker.patch("kasa.discover.Discover.discover_single", return_value=dev) - res = await runner.invoke( - cli, - [ - "--host", - "127.0.0.1", - "--username", - "foo", - "--password", - "bar", - ], - ) - assert res.exit_code == 0 - - -@pytest.mark.parametrize("auth_param", ["--username", "--password"]) -async def test_invalid_credential_params(auth_param): - """Test for handling only one of username or password supplied.""" - runner = CliRunner() - - 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(): - """Test that defining both --host or --alias gives an error.""" - runner = CliRunner() - - 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): - """Test discovery output.""" - runner = CliRunner() - res = await runner.invoke( - cli, - [ - "--discovery-timeout", - 0, - "--username", - "foo", - "--password", - "bar", - "--verbose", - "discover", - ], - ) - assert res.exit_code == 0 - - -async def test_discover_unsupported(unsupported_device_info): - """Test discovery output.""" - runner = CliRunner() - 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 - assert "== Discovery Result ==" in res.output - - -async def test_host_unsupported(unsupported_device_info): - """Test discovery output.""" - runner = CliRunner() - host = "127.0.0.1" - - res = await runner.invoke( - cli, - [ - "--host", - host, - "--username", - "foo", - "--password", - "bar", - ], - ) - - assert res.exit_code != 0 - assert isinstance(res.exception, UnsupportedDeviceException) - - -@new_discovery -async def test_discover_auth_failed(discovery_mock, mocker): - """Test discovery output.""" - runner = CliRunner() - 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=AuthenticationException("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): - """Test discovery output.""" - runner = CliRunner() - 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=AuthenticationException("Failed to authenticate"), - ) - res = await runner.invoke( - cli, - [ - "--host", - host, - "--username", - "foo", - "--password", - "bar", - ], - ) - - assert res.exit_code != 0 - assert isinstance(res.exception, AuthenticationException) - - -@pytest.mark.parametrize("device_type", list(TYPE_TO_CLASS)) -async def test_type_param(device_type, mocker): - """Test for handling only one of username or password supplied.""" - runner = CliRunner() - - result_device = FileNotFoundError - pass_dev = click.make_pass_decorator(SmartDevice) - - @pass_dev - async def _state(dev: SmartDevice): - nonlocal result_device - result_device = dev - - mocker.patch("kasa.cli.state", new=_state) - expected_type = 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) diff --git a/kasa/tests/test_device_factory.py b/kasa/tests/test_device_factory.py deleted file mode 100644 index 82802c40..00000000 --- a/kasa/tests/test_device_factory.py +++ /dev/null @@ -1,158 +0,0 @@ -# type: ignore -import logging -from typing import Type - -import httpx -import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 - -from kasa import ( - Credentials, - DeviceType, - Discover, - SmartBulb, - SmartDevice, - SmartDeviceException, - SmartDimmer, - SmartLightStrip, - SmartPlug, -) -from kasa.device_factory import connect, get_protocol -from kasa.deviceconfig import ( - ConnectionType, - DeviceConfig, - DeviceFamilyType, - EncryptType, -) -from kasa.discover import DiscoveryResult - - -def _get_connection_type_device_class(the_fixture_data): - if "discovery_result" in the_fixture_data: - discovery_info = {"result": the_fixture_data["discovery_result"]} - device_class = Discover._get_device_class(discovery_info) - dr = DiscoveryResult(**discovery_info["result"]) - - connection_type = ConnectionType.from_values( - dr.device_type, dr.mgt_encrypt_schm.encrypt_type - ) - else: - connection_type = ConnectionType.from_values( - DeviceFamilyType.IotSmartPlugSwitch.value, EncryptType.Xor.value - ) - device_class = Discover._get_device_class(the_fixture_data) - - return connection_type, device_class - - -async def test_connect( - all_fixture_data: dict, - mocker, -): - """Test that if the protocol is passed in it gets set correctly.""" - host = "127.0.0.1" - ctype, device_class = _get_connection_type_device_class(all_fixture_data) - - mocker.patch("kasa.IotProtocol.query", return_value=all_fixture_data) - mocker.patch("kasa.SmartProtocol.query", return_value=all_fixture_data) - mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=all_fixture_data) - - config = DeviceConfig( - host=host, credentials=Credentials("foor", "bar"), connection_type=ctype - ) - protocol_class = get_protocol(config).__class__ - - dev = await connect( - config=config, - ) - assert isinstance(dev, device_class) - assert isinstance(dev.protocol, protocol_class) - - assert dev.config == config - - -@pytest.mark.parametrize("custom_port", [123, None]) -async def test_connect_custom_port(all_fixture_data: dict, mocker, custom_port): - """Make sure that connect returns an initialized SmartDevice instance.""" - host = "127.0.0.1" - - ctype, _ = _get_connection_type_device_class(all_fixture_data) - config = DeviceConfig( - host=host, - port_override=custom_port, - connection_type=ctype, - credentials=Credentials("dummy_user", "dummy_password"), - ) - default_port = 80 if "discovery_result" in all_fixture_data else 9999 - - ctype, _ = _get_connection_type_device_class(all_fixture_data) - mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=all_fixture_data) - mocker.patch("kasa.IotProtocol.query", return_value=all_fixture_data) - mocker.patch("kasa.SmartProtocol.query", return_value=all_fixture_data) - dev = await connect(config=config) - assert issubclass(dev.__class__, SmartDevice) - assert dev.port == custom_port or dev.port == default_port - - -async def test_connect_logs_connect_time( - all_fixture_data: dict, caplog: pytest.LogCaptureFixture, mocker -): - """Test that the connect time is logged when debug logging is enabled.""" - ctype, _ = _get_connection_type_device_class(all_fixture_data) - mocker.patch("kasa.IotProtocol.query", return_value=all_fixture_data) - mocker.patch("kasa.SmartProtocol.query", return_value=all_fixture_data) - mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=all_fixture_data) - - host = "127.0.0.1" - 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(all_fixture_data: dict, mocker): - """Make sure that connect fails when query fails.""" - host = "127.0.0.1" - mocker.patch("kasa.TPLinkSmartHomeProtocol.query", side_effect=SmartDeviceException) - mocker.patch("kasa.IotProtocol.query", side_effect=SmartDeviceException) - mocker.patch("kasa.SmartProtocol.query", side_effect=SmartDeviceException) - - ctype, _ = _get_connection_type_device_class(all_fixture_data) - config = DeviceConfig( - host=host, credentials=Credentials("foor", "bar"), connection_type=ctype - ) - with pytest.raises(SmartDeviceException): - await connect(config=config) - - -async def test_connect_http_client(all_fixture_data, mocker): - """Make sure that discover_single returns an initialized SmartDevice instance.""" - host = "127.0.0.1" - - ctype, _ = _get_connection_type_device_class(all_fixture_data) - - mocker.patch("kasa.IotProtocol.query", return_value=all_fixture_data) - mocker.patch("kasa.SmartProtocol.query", return_value=all_fixture_data) - mocker.patch("kasa.TPLinkSmartHomeProtocol.query", return_value=all_fixture_data) - - http_client = httpx.AsyncClient() - - config = DeviceConfig( - host=host, credentials=Credentials("foor", "bar"), connection_type=ctype - ) - dev = await connect(config=config) - if ctype.encryption_type != EncryptType.Xor: - assert dev.protocol._transport._http_client != http_client - - config = DeviceConfig( - host=host, - credentials=Credentials("foor", "bar"), - connection_type=ctype, - http_client=http_client, - ) - dev = await connect(config=config) - if ctype.encryption_type != EncryptType.Xor: - assert dev.protocol._transport._http_client == http_client diff --git a/kasa/tests/test_deviceconfig.py b/kasa/tests/test_deviceconfig.py deleted file mode 100644 index 43282915..00000000 --- a/kasa/tests/test_deviceconfig.py +++ /dev/null @@ -1,62 +0,0 @@ -from json import dumps as json_dumps -from json import loads as json_loads - -import httpx - -from kasa.credentials import Credentials -from kasa.deviceconfig import ( - ConnectionType, - DeviceConfig, - DeviceFamilyType, - EncryptType, -) - - -def test_serialization(): - config = DeviceConfig(host="Foo", http_client=httpx.AsyncClient()) - 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 - - -def test_credentials_hash(): - config = DeviceConfig( - host="Foo", - http_client=httpx.AsyncClient(), - credentials=Credentials("foo", "bar"), - ) - config_dict = config.to_dict(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 - - -def test_blank_credentials_hash(): - config = DeviceConfig( - host="Foo", - http_client=httpx.AsyncClient(), - credentials=Credentials("foo", "bar"), - ) - config_dict = config.to_dict(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 - - -def test_exclude_credentials(): - config = DeviceConfig( - host="Foo", - http_client=httpx.AsyncClient(), - credentials=Credentials("foo", "bar"), - ) - config_dict = config.to_dict(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/kasa/tests/test_dimmer.py b/kasa/tests/test_dimmer.py deleted file mode 100644 index b5e98b78..00000000 --- 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, 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 f0cce517..00000000 --- a/kasa/tests/test_discovery.py +++ /dev/null @@ -1,343 +0,0 @@ -# type: ignore -import logging -import re -import socket - -import httpx -import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 - -from kasa import ( - Credentials, - DeviceType, - Discover, - SmartDevice, - SmartDeviceException, - protocol, -) -from kasa.deviceconfig import ( - ConnectionType, - DeviceConfig, - DeviceFamilyType, - EncryptType, -) -from kasa.discover import DiscoveryResult, _DiscoverProtocol, json_dumps -from kasa.exceptions import AuthenticationException, UnsupportedDeviceException - -from .conftest import bulb, bulb_iot, dimmer, lightstrip, new_discovery, plug, strip - -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, -} - - -@plug -async def test_type_detection_plug(dev: SmartDevice): - d = Discover._get_device_class(dev._last_update)("localhost") - assert d.is_plug - assert d.device_type == DeviceType.Plug - - -@bulb_iot -async def test_type_detection_bulb(dev: SmartDevice): - 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 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._last_update)("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._last_update)("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._last_update)("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(UnsupportedDeviceException): - Discover._get_device_class(invalid_info) - - -@pytest.mark.parametrize("custom_port", [123, None]) -# @pytest.mark.parametrize("discovery_mock", [("127.0.0.1",123), ("127.0.0.1",None)], indirect=True) -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 - - device_class = Discover._get_device_class(discovery_mock.discovery_data) - update_mock = mocker.patch.object(device_class, "update") - - x = await Discover.discover_single( - host, port=custom_port, credentials=Credentials() - ) - assert issubclass(x.__class__, SmartDevice) - assert x._discovery_info is not None - assert x.port == custom_port or x.port == discovery_mock.default_port - assert update_mock.call_count == 0 - if discovery_mock.default_port == 80: - assert x.alias is None - - ct = ConnectionType.from_values( - discovery_mock.device_type, - discovery_mock.encrypt_type, - discovery_mock.login_version, - ) - uses_http = discovery_mock.default_port == 80 - config = DeviceConfig( - host=host, - port_override=custom_port, - connection_type=ct, - uses_http=uses_http, - credentials=Credentials(), - ) - assert x.config == config - - -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__, SmartDevice) - 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(SmartDeviceException): - x = await Discover.discover_single(host, credentials=Credentials()) - - -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( - UnsupportedDeviceException, - ): - 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( - SmartDeviceException, 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}}}, - ), - ("Unknown device type: foo", {"system": {"get_sysinfo": {"type": "foo"}}}), -] - - -@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" - - def mock_discover(self): - self.datagram_received( - protocol.TPLinkSmartHomeProtocol.encrypt(json_dumps(data))[4:], (host, 9999) - ) - - mocker.patch.object(_DiscoverProtocol, "do_discover", mock_discover) - - with pytest.raises(SmartDeviceException, match=msg): - await Discover.discover_single(host) - - -async def test_discover_send(mocker): - """Test discovery parameters.""" - proto = _DiscoverProtocol() - assert proto.discovery_packets == 3 - assert proto.target == ("255.255.255.255", 9999) - transport = mocker.patch.object(proto, "transport") - proto.do_discover() - assert transport.sendto.call_count == proto.discovery_packets * 2 - - -async def test_discover_datagram_received(mocker, discovery_data): - """Verify that datagram received fills discovered_devices.""" - proto = _DiscoverProtocol() - - mocker.patch.object(protocol.TPLinkSmartHomeProtocol, "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__, SmartDevice) - 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(protocol.TPLinkSmartHomeProtocol, "encrypt") - mocker.patch.object(protocol.TPLinkSmartHomeProtocol, "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=AuthenticationException("Failed to authenticate"), - ) - - with pytest.raises( - AuthenticationException, - 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_data): - device = SmartDevice("127.0.0.7") - discover_info = DiscoveryResult(**discovery_data["result"]) - discover_dump = discover_info.get_dict() - discover_dump["alias"] = "foobar" - discover_dump["model"] = discover_dump["device_model"] - device.update_from_discover_info(discover_dump) - - assert device.alias == "foobar" - assert device.mac == discover_dump["mac"].replace("-", ":") - assert device.model == discover_dump["device_model"] - - with pytest.raises( - SmartDeviceException, - 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 = httpx.AsyncClient() - - x: SmartDevice = await Discover.discover_single(host) - - assert x.config.uses_http == (discovery_mock.default_port == 80) - - if discovery_mock.default_port == 80: - assert x.protocol._transport._http_client != http_client - x.config.http_client = http_client - assert x.protocol._transport._http_client == http_client - - -async def test_discover_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 = httpx.AsyncClient() - - devices = await Discover.discover(discovery_timeout=0) - x: SmartDevice = devices[host] - assert x.config.uses_http == (discovery_mock.default_port == 80) - - if discovery_mock.default_port == 80: - assert x.protocol._transport._http_client != http_client - x.config.http_client = http_client - assert x.protocol._transport._http_client == http_client diff --git a/kasa/tests/test_emeter.py b/kasa/tests/test_emeter.py deleted file mode 100644 index fdb219b5..00000000 --- a/kasa/tests/test_emeter.py +++ /dev/null @@ -1,148 +0,0 @@ -import datetime -from unittest.mock import Mock - -import pytest - -from kasa import EmeterStatus, SmartDeviceException -from kasa.modules.emeter import Emeter - -from .conftest import has_emeter, has_emeter_iot, no_emeter -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): - assert dev.has_emeter - - current_emeter = await dev.get_emeter_realtime() - CURRENT_CONSUMPTION_SCHEMA(current_emeter) - - -@has_emeter_iot -@pytest.mark.requires_dummy -async def test_get_emeter_daily(dev): - 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_iot -@pytest.mark.requires_dummy -async def test_get_emeter_monthly(dev): - 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_iot -async def test_emeter_status(dev): - 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 and not dev.is_light_strip: - 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.has_emeter: - x = await dev.current_consumption() - assert isinstance(x, float) - assert x >= 0.0 - else: - assert await dev.current_consumption() is None - - -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.emeter_today == 0.500 diff --git a/kasa/tests/test_lightstrip.py b/kasa/tests/test_lightstrip.py deleted file mode 100644 index 109b9d7c..00000000 --- a/kasa/tests/test_lightstrip.py +++ /dev/null @@ -1,72 +0,0 @@ -import pytest - -from kasa import DeviceType, SmartLightStrip -from kasa.exceptions import SmartDeviceException - -from .conftest import lightstrip - - -@lightstrip -async def test_lightstrip_length(dev: SmartLightStrip): - assert dev.is_light_strip - assert dev.device_type == DeviceType.LightStrip - assert dev.length == dev.sys_info["length"] - - -@lightstrip -async def test_lightstrip_effect(dev: SmartLightStrip): - assert isinstance(dev.effect, dict) - for k in ["brightness", "custom", "enable", "id", "name"]: - assert k in dev.effect - - -@lightstrip -async def test_effects_lightstrip_set_effect(dev: SmartLightStrip): - with pytest.raises(SmartDeviceException): - await dev.set_effect("Not real") - - await dev.set_effect("Candy Cane") - assert dev.effect["name"] == "Candy Cane" - assert dev.state_information["Effect"] == "Candy Cane" - - -@lightstrip -@pytest.mark.parametrize("brightness", [100, 50]) -async def test_effects_lightstrip_set_effect_brightness( - dev: SmartLightStrip, brightness, mocker -): - query_helper = mocker.patch("kasa.SmartLightStrip._query_helper") - - # test that default brightness works (100 for candy cane) - if brightness == 100: - await dev.set_effect("Candy Cane") - else: - await dev.set_effect("Candy Cane", brightness=brightness) - - args, kwargs = query_helper.call_args_list[0] - payload = args[2] - assert payload["brightness"] == brightness - - -@lightstrip -@pytest.mark.parametrize("transition", [500, 1000]) -async def test_effects_lightstrip_set_effect_transition( - dev: SmartLightStrip, transition, mocker -): - query_helper = mocker.patch("kasa.SmartLightStrip._query_helper") - - # test that default (500 for candy cane) transition works - if transition == 500: - await dev.set_effect("Candy Cane") - else: - await dev.set_effect("Candy Cane", transition=transition) - - args, kwargs = query_helper.call_args_list[0] - payload = args[2] - assert payload["transition"] == transition - - -@lightstrip -async def test_effects_lightstrip_has_effects(dev: SmartLightStrip): - assert dev.has_effects is True - assert dev.effect_list diff --git a/kasa/tests/test_plug.py b/kasa/tests/test_plug.py deleted file mode 100644 index e9e1592f..00000000 --- a/kasa/tests/test_plug.py +++ /dev/null @@ -1,41 +0,0 @@ -from kasa import DeviceType - -from .conftest import plug, plug_smart -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) - 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 - # 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 diff --git a/kasa/tests/test_protocol.py b/kasa/tests/test_protocol.py deleted file mode 100644 index 563b8176..00000000 --- a/kasa/tests/test_protocol.py +++ /dev/null @@ -1,393 +0,0 @@ -import asyncio -import errno -import importlib -import inspect -import json -import logging -import pkgutil -import struct -import sys - -import pytest - -from ..aestransport import AesTransport -from ..credentials import Credentials -from ..deviceconfig import DeviceConfig -from ..exceptions import SmartDeviceException -from ..klaptransport import KlapTransport, KlapTransportV2 -from ..protocol import ( - BaseTransport, - TPLinkProtocol, - TPLinkSmartHomeProtocol, - _XorTransport, -) - - -@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) - config = DeviceConfig("127.0.0.1") - with pytest.raises(SmartDeviceException): - await TPLinkSmartHomeProtocol(transport=_XorTransport(config=config)).query( - {}, retry_count=retry_count - ) - - assert conn.call_count == retry_count + 1 - - -async def test_protocol_no_retry_on_unreachable(mocker): - conn = mocker.patch( - "asyncio.open_connection", - side_effect=OSError(errno.EHOSTUNREACH, "No route to host"), - ) - config = DeviceConfig("127.0.0.1") - with pytest.raises(SmartDeviceException): - await TPLinkSmartHomeProtocol(transport=_XorTransport(config=config)).query( - {}, retry_count=5 - ) - - assert conn.call_count == 1 - - -async def test_protocol_no_retry_connection_refused(mocker): - conn = mocker.patch( - "asyncio.open_connection", - side_effect=ConnectionRefusedError, - ) - config = DeviceConfig("127.0.0.1") - with pytest.raises(SmartDeviceException): - await TPLinkSmartHomeProtocol(transport=_XorTransport(config=config)).query( - {}, retry_count=5 - ) - - assert conn.call_count == 1 - - -async def test_protocol_retry_recoverable_error(mocker): - conn = mocker.patch( - "asyncio.open_connection", - side_effect=OSError(errno.ECONNRESET, "Connection reset by peer"), - ) - config = DeviceConfig("127.0.0.1") - with pytest.raises(SmartDeviceException): - await TPLinkSmartHomeProtocol(transport=_XorTransport(config=config)).query( - {}, retry_count=5 - ) - - assert conn.call_count == 6 - - -@pytest.mark.parametrize("retry_count", [1, 3, 5]) -async def test_protocol_reconnect(mocker, retry_count): - remaining = retry_count - encrypted = TPLinkSmartHomeProtocol.encrypt('{"great":"success"}')[ - TPLinkSmartHomeProtocol.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 == TPLinkSmartHomeProtocol.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) - return reader, writer - - config = DeviceConfig("127.0.0.1") - protocol = TPLinkSmartHomeProtocol(transport=_XorTransport(config=config)) - mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) - response = await protocol.query({}, retry_count=retry_count) - assert response == {"great": "success"} - - -async def test_protocol_handles_cancellation_during_write(mocker): - attempts = 0 - encrypted = TPLinkSmartHomeProtocol.encrypt('{"great":"success"}')[ - TPLinkSmartHomeProtocol.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 == TPLinkSmartHomeProtocol.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) - return reader, writer - - config = DeviceConfig("127.0.0.1") - protocol = TPLinkSmartHomeProtocol(transport=_XorTransport(config=config)) - mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) - with pytest.raises(asyncio.CancelledError): - await protocol.query({}) - assert protocol.writer is None - response = await protocol.query({}) - assert response == {"great": "success"} - - -async def test_protocol_handles_cancellation_during_connection(mocker): - attempts = 0 - encrypted = TPLinkSmartHomeProtocol.encrypt('{"great":"success"}')[ - TPLinkSmartHomeProtocol.BLOCK_SIZE : - ] - - async def _mock_read(byte_count): - nonlocal encrypted - if byte_count == TPLinkSmartHomeProtocol.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) - return reader, writer - - config = DeviceConfig("127.0.0.1") - protocol = TPLinkSmartHomeProtocol(transport=_XorTransport(config=config)) - mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) - with pytest.raises(asyncio.CancelledError): - await protocol.query({}) - assert protocol.writer is None - response = await protocol.query({}) - assert response == {"great": "success"} - - -@pytest.mark.parametrize("log_level", [logging.WARNING, logging.DEBUG]) -async def test_protocol_logging(mocker, caplog, log_level): - caplog.set_level(log_level) - logging.getLogger("kasa").setLevel(log_level) - encrypted = TPLinkSmartHomeProtocol.encrypt('{"great":"success"}')[ - TPLinkSmartHomeProtocol.BLOCK_SIZE : - ] - - async def _mock_read(byte_count): - nonlocal encrypted - if byte_count == TPLinkSmartHomeProtocol.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) - return reader, writer - - config = DeviceConfig("127.0.0.1") - protocol = TPLinkSmartHomeProtocol(transport=_XorTransport(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("custom_port", [123, None]) -async def test_protocol_custom_port(mocker, custom_port): - encrypted = TPLinkSmartHomeProtocol.encrypt('{"great":"success"}')[ - TPLinkSmartHomeProtocol.BLOCK_SIZE : - ] - - async def _mock_read(byte_count): - nonlocal encrypted - if byte_count == TPLinkSmartHomeProtocol.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) - return reader, writer - - config = DeviceConfig("127.0.0.1", port_override=custom_port) - protocol = TPLinkSmartHomeProtocol(transport=_XorTransport(config=config)) - mocker.patch("asyncio.open_connection", side_effect=aio_mock_writer) - response = await protocol.query({}) - assert response == {"great": "success"} - - -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) - - -def _get_subclasses(of_class): - import kasa - - 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): - subclasses.add((name, obj)) - return subclasses - - -@pytest.mark.parametrize( - "class_name_obj", _get_subclasses(TPLinkProtocol), ids=lambda t: t[0] -) -def test_protocol_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" - and params[0].kind == inspect.Parameter.POSITIONAL_OR_KEYWORD - ) - assert ( - params[1].name == "transport" - and 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" - and params[0].kind == inspect.Parameter.POSITIONAL_OR_KEYWORD - ) - assert ( - params[1].name == "config" and params[1].kind == inspect.Parameter.KEYWORD_ONLY - ) - - -@pytest.mark.parametrize( - "transport_class", [AesTransport, KlapTransport, KlapTransportV2, _XorTransport] -) -async def test_transport_credentials_hash(mocker, transport_class): - 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 diff --git a/kasa/tests/test_readme_examples.py b/kasa/tests/test_readme_examples.py deleted file mode 100644 index 5772ba42..00000000 --- a/kasa/tests/test_readme_examples.py +++ /dev/null @@ -1,70 +0,0 @@ -import asyncio -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 = asyncio.run(get_device_for_file("KL130(US)_1.0_1.8.11.json", "IOT")) - 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 = asyncio.run(get_device_for_file("HS110(EU)_1.0_1.2.5.json", "IOT")) - 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 = asyncio.run(get_device_for_file("HS110(EU)_1.0_1.2.5.json", "IOT")) - 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 = asyncio.run(get_device_for_file("KP303(UK)_1.0_1.0.3.json", "IOT")) - 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 = asyncio.run(get_device_for_file("HS220(US)_1.0_1.5.7.json", "IOT")) - 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 = asyncio.run(get_device_for_file("KL430(US)_1.0_1.0.10.json", "IOT")) - 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"] - - -def test_discovery_examples(mocker): - """Test discovery examples.""" - p = asyncio.run(get_device_for_file("KP303(UK)_1.0_1.0.3.json", "IOT")) - - mocker.patch("kasa.discover.Discover.discover", return_value=[p]) - res = xdoctest.doctest_module("kasa.discover", "all") - assert not res["failed"] diff --git a/kasa/tests/test_smartdevice.py b/kasa/tests/test_smartdevice.py deleted file mode 100644 index b2ae9c33..00000000 --- a/kasa/tests/test_smartdevice.py +++ /dev/null @@ -1,265 +0,0 @@ -import inspect -from datetime import datetime -from unittest.mock import Mock, patch - -import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 - -import kasa -from kasa import Credentials, DeviceConfig, SmartDevice, SmartDeviceException - -from .conftest import device_iot, handle_turn_on, has_emeter_iot, no_emeter_iot, turn_on -from .newfakes import PLUG_SCHEMA, TZ_SCHEMA, FakeTransportProtocol - -# List of all SmartXXX classes including the SmartDevice base class -smart_device_classes = [ - dc - for (mn, dc) in inspect.getmembers( - kasa, - lambda member: inspect.isclass(member) - and (member == SmartDevice or issubclass(member, SmartDevice)), - ) -] - - -@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(dev): - with patch.object( - FakeTransportProtocol, "query", side_effect=SmartDeviceException - ), pytest.raises(SmartDeviceException): - 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 = None - dev._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 = None - dev._features = set() - spy = mocker.spy(dev.protocol, "query") - await dev.update() - # 2 calls are necessary as some devices crash on unexpected modules - # See #105, #120, #161 - assert spy.call_count == 2 - - -@device_iot -async def test_query_helper(dev): - with pytest.raises(SmartDeviceException): - await dev._query_helper("test", "testcmd", {}) - # TODO check for unwrapping? - - -@device_iot -@turn_on -async def test_state(dev, turn_on): - await handle_turn_on(dev, turn_on) - orig_state = dev.is_on - 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 -async def test_alias(dev): - test_alias = "TEST1234" - original = dev.alias - - assert isinstance(original, str) - await dev.set_alias(test_alias) - await dev.update() - assert dev.alias == test_alias - - await dev.set_alias(original) - await dev.update() - assert dev.alias == original - - -@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 not dev.is_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(await dev.get_time(), datetime) - - -@device_iot -async def test_timezone(dev): - TZ_SCHEMA(await dev.get_timezone()) - - -@device_iot -async def test_hw_info(dev): - PLUG_SCHEMA(dev.hw_info) - - -@device_iot -async def test_location(dev): - PLUG_SCHEMA(dev.location) - - -@device_iot -async def test_rssi(dev): - PLUG_SCHEMA({"rssi": dev.rssi}) # wrapping for vol - - -@device_iot -async def test_mac(dev): - PLUG_SCHEMA({"mac": dev.mac}) # wrapping for val - - -@device_iot -async def test_representation(dev): - import re - - pattern = re.compile("<.* model .* at .* (.*), is_on: .* - dev specific: .*>") - assert pattern.match(str(dev)) - - -@device_iot -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 - - -@device_iot -async def test_children(dev): - """Make sure that children property is exposed by every device.""" - if dev.is_strip: - assert len(dev.children) > 0 - assert dev.has_children is True - else: - assert len(dev.children) == 0 - assert dev.has_children is False - - -@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.features == set(sysinfo["feature"].split(":")) - else: - assert dev.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 - - -@pytest.mark.parametrize("device_class", smart_device_classes) -def test_device_class_ctors(device_class): - """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) - dev = device_class(host, config=config) - assert dev.host == host - assert dev.port == port - assert dev.credentials == credentials - - -@device_iot -async def test_modules_preserved(dev: SmartDevice): - """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" - - -async def test_create_smart_device_with_timeout(): - """Make sure timeout is passed to the protocol.""" - host = "127.0.0.1" - 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 = Mock() - 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 SmartDevice.connect(config=config) - assert dev is mock - - connect.assert_called_once_with( - host=None, - config=config, - ) - - -@device_iot -async def test_modules_not_supported(dev: SmartDevice): - """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 diff --git a/kasa/tests/test_smartprotocol.py b/kasa/tests/test_smartprotocol.py deleted file mode 100644 index 301e367f..00000000 --- a/kasa/tests/test_smartprotocol.py +++ /dev/null @@ -1,83 +0,0 @@ -import errno -import json -import logging -import secrets -import struct -import sys -import time -from contextlib import nullcontext as does_not_raise -from itertools import chain - -import httpx -import pytest - -from ..aestransport import AesTransport -from ..credentials import Credentials -from ..deviceconfig import DeviceConfig -from ..exceptions import ( - SMART_RETRYABLE_ERRORS, - SMART_TIMEOUT_ERRORS, - SmartDeviceException, - SmartErrorCode, -) -from ..iotprotocol import IotProtocol -from ..klaptransport import KlapEncryptionSession, KlapTransport, _sha256 -from ..smartprotocol import SmartProtocol - -DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}} -ERRORS = [e for e in SmartErrorCode if e != 0] - - -@pytest.mark.parametrize("error_code", ERRORS, ids=lambda e: e.name) -async def test_smart_device_errors(mocker, error_code): - host = "127.0.0.1" - mock_response = {"result": {"great": "success"}, "error_code": error_code.value} - - mocker.patch.object(AesTransport, "perform_handshake") - mocker.patch.object(AesTransport, "perform_login") - - send_mock = mocker.patch.object(AesTransport, "send", return_value=mock_response) - - config = DeviceConfig(host, credentials=Credentials("foo", "bar")) - protocol = SmartProtocol(transport=AesTransport(config=config)) - with pytest.raises(SmartDeviceException): - await protocol.query(DUMMY_QUERY, retry_count=2) - - if error_code in chain(SMART_TIMEOUT_ERRORS, SMART_RETRYABLE_ERRORS): - expected_calls = 3 - else: - expected_calls = 1 - assert send_mock.call_count == expected_calls - - -@pytest.mark.parametrize("error_code", ERRORS, ids=lambda e: e.name) -async def test_smart_device_errors_in_multiple_request(mocker, error_code): - host = "127.0.0.1" - 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, - } - - mocker.patch.object(AesTransport, "perform_handshake") - mocker.patch.object(AesTransport, "perform_login") - - send_mock = mocker.patch.object(AesTransport, "send", return_value=mock_response) - config = DeviceConfig(host, credentials=Credentials("foo", "bar")) - protocol = SmartProtocol(transport=AesTransport(config=config)) - with pytest.raises(SmartDeviceException): - await protocol.query(DUMMY_QUERY, retry_count=2) - if error_code in chain(SMART_TIMEOUT_ERRORS, SMART_RETRYABLE_ERRORS): - expected_calls = 3 - else: - expected_calls = 1 - assert send_mock.call_count == expected_calls diff --git a/kasa/transports/__init__.py b/kasa/transports/__init__.py new file mode 100644 index 00000000..192b4156 --- /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 00000000..3466ca98 --- /dev/null +++ b/kasa/transports/aestransport.py @@ -0,0 +1,498 @@ +"""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.""" + 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 00000000..1f1ed7d9 --- /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/klaptransport.py b/kasa/transports/klaptransport.py similarity index 61% rename from kasa/klaptransport.py rename to kasa/transports/klaptransport.py index 220844a2..8934b2cc 100644 --- a/kasa/klaptransport.py +++ b/kasa/transports/klaptransport.py @@ -26,11 +26,11 @@ 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 succesful. It generally will be because this -implemenation checks the auth_hash it recevied during handshake1 +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 initialisation vector +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 @@ -40,41 +40,48 @@ 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 struct import time -from pprint import pformat as pf -from typing import Any, Optional, Tuple +from asyncio import Future +from collections.abc import Generator +from typing import TYPE_CHECKING, Any, cast -import httpx -from cryptography.hazmat.primitives import hashes, padding +from cryptography.hazmat.primitives import padding from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from yarl import URL -from .credentials import Credentials -from .deviceconfig import DeviceConfig -from .exceptions import AuthenticationException, SmartDeviceException -from .json import loads as json_loads -from .protocol import BaseTransport, md5 +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__) -logging.getLogger("httpx").propagate = False + + +ONE_DAY_SECONDS = 86400 +SESSION_EXPIRE_BUFFER_SECONDS = 60 * 20 + +PACK_SIGNED_LONG = struct.Struct(">l").pack def _sha256(payload: bytes) -> bytes: - digest = hashes.Hash(hashes.SHA256()) # noqa: S303 - digest.update(payload) - hash = digest.finalize() - return hash + return hashlib.sha256(payload).digest() # noqa: S324 def _sha1(payload: bytes) -> bytes: - digest = hashes.Hash(hashes.SHA1()) # noqa: S303 - digest.update(payload) - return digest.finalize() + return hashlib.sha1(payload).digest() # noqa: S324 class KlapTransport(BaseTransport): @@ -85,11 +92,8 @@ class KlapTransport(BaseTransport): """ DEFAULT_PORT: int = 80 - DISCOVERY_QUERY = {"system": {"get_sysinfo": None}} - - KASA_SETUP_EMAIL = "kasa@tp-link.net" - KASA_SETUP_PASSWORD = "kasaSetup" # noqa: S105 SESSION_COOKIE_NAME = "TP_SESSIONID" + TIMEOUT_COOKIE_NAME = "TIMEOUT" def __init__( self, @@ -98,8 +102,8 @@ class KlapTransport(BaseTransport): ) -> None: super().__init__(config=config) - self._default_http_client: Optional[httpx.AsyncClient] = None - self._local_seed: Optional[bytes] = None + 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: @@ -109,58 +113,34 @@ class KlapTransport(BaseTransport): 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._kasa_setup_auth_hash = None - self._blank_auth_hash = None + 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 = False + self._handshake_done: bool = False - self._encryption_session: Optional[KlapEncryptionSession] = None - self._session_expire_at: Optional[float] = None + self._encryption_session: KlapEncryptionSession | None = None + self._session_expire_at: float | None = None - self._session_cookie = None + self._session_cookie: dict[str, Any] | None = None _LOGGER.debug("Created KLAP transport for %s", self._host) + self._app_url = URL(f"http://{self._host}:{self._port}/app") + self._request_url = self._app_url / "request" @property - def default_port(self): + def default_port(self) -> int: """Default port for the transport.""" return self.DEFAULT_PORT @property - def credentials_hash(self) -> str: + 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() - @property - def _http_client(self) -> httpx.AsyncClient: - if self._config.http_client: - return self._config.http_client - if not self._default_http_client: - self._default_http_client = httpx.AsyncClient() - return self._default_http_client - - async def client_post(self, url, params=None, data=None): - """Send an http post request to the device.""" - response_data = None - cookies = None - if self._session_cookie: - cookies = httpx.Cookies() - cookies.set(self.SESSION_COOKIE_NAME, self._session_cookie) - self._http_client.cookies.clear() - resp = await self._http_client.post( - url, - params=params, - data=data, - timeout=self._timeout, - cookies=cookies, - ) - if resp.status_code == 200: - response_data = resp.content - - return resp.status_code, response_data - - async def perform_handshake1(self) -> Tuple[bytes, bytes, bytes]: + async def perform_handshake1(self) -> tuple[bytes, bytes, bytes]: """Perform handshake1.""" local_seed: bytes = secrets.token_bytes(16) @@ -170,14 +150,14 @@ class KlapTransport(BaseTransport): payload = local_seed - url = f"http://{self._host}/app/handshake1" + url = self._app_url / "handshake1" - response_status, response_data = await self.client_post(url, data=payload) + response_status, response_data = await self._http_client.post(url, data=payload) if _LOGGER.isEnabledFor(logging.DEBUG): _LOGGER.debug( - "Handshake1 posted at %s. Host is %s, Response" - + "status is %s, Request was %s", + "Handshake1 posted at %s. Host is %s, " + "Response status is %s, Request was %s", datetime.datetime.now(), self._host, response_status, @@ -185,17 +165,24 @@ class KlapTransport(BaseTransport): ) if response_status != 200: - raise AuthenticationException( + 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", + "Server remote_seed is: %s, server hash is: %s", datetime.datetime.now(), self._host, remote_seed.hex(), @@ -211,27 +198,28 @@ class KlapTransport(BaseTransport): _LOGGER.debug("handshake1 hashes match with expected credentials") return local_seed, remote_seed, self._local_auth_hash # type: ignore - # Now check against the default kasa setup credentials - if not self._kasa_setup_auth_hash: - kasa_setup_creds = Credentials( - username=self.KASA_SETUP_EMAIL, - password=self.KASA_SETUP_PASSWORD, - ) - self._kasa_setup_auth_hash = self.generate_auth_hash(kasa_setup_creds) + # 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 + ) - kasa_setup_seed_auth_hash = self.handshake1_seed_auth_hash( - local_seed, - remote_seed, - self._kasa_setup_auth_hash, # type: ignore - ) - - if kasa_setup_seed_auth_hash == server_hash: - _LOGGER.debug( - "Server response doesn't match our expected hash on ip %s" - + " but an authentication with kasa setup credentials matched", - self._host, + default_credentials_seed_auth_hash = self.handshake1_seed_auth_hash( + local_seed, + remote_seed, + self._default_credentials_auth_hash[key], # type: ignore ) - return local_seed, remote_seed, self._kasa_setup_auth_hash # type: ignore + + if default_credentials_seed_auth_hash == server_hash: + _LOGGER.debug( + "Server response doesn't match our expected hash on ip %s, " + "but an authentication with %s default credentials matched", + 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() @@ -247,33 +235,37 @@ class KlapTransport(BaseTransport): if blank_seed_auth_hash == server_hash: _LOGGER.debug( - "Server response doesn't match our expected hash on ip %s" - + " but an authentication with blank credentials matched", + "Server response doesn't match our expected hash on ip %s, " + "but an authentication with blank credentials matched", self._host, ) return local_seed, remote_seed, self._blank_auth_hash # type: ignore msg = f"Server response doesn't match our challenge on ip {self._host}" _LOGGER.debug(msg) - raise AuthenticationException(msg) + raise AuthenticationError(msg) async def perform_handshake2( - self, local_seed, remote_seed, auth_hash - ) -> "KlapEncryptionSession": + self, local_seed: bytes, remote_seed: bytes, auth_hash: bytes + ) -> KlapEncryptionSession: """Perform handshake2.""" # Handshake 2 has the following payload: # sha256(serverBytes | authenticator) - url = f"http://{self._host}/app/handshake2" + url = self._app_url / "handshake2" payload = self.handshake2_seed_auth_hash(local_seed, remote_seed, auth_hash) - response_status, response_data = await self.client_post(url, data=payload) + response_status, _ = await self._http_client.post( + url, + data=payload, + cookies_dict=self._session_cookie, + ) if _LOGGER.isEnabledFor(logging.DEBUG): _LOGGER.debug( - "Handshake2 posted %s. Host is %s, Response status is %s, " - + "Request was %s", + "Handshake2 posted %s. Host is %s, " + "Response status is %s, Request was %s", datetime.datetime.now(), self._host, response_status, @@ -281,13 +273,15 @@ class KlapTransport(BaseTransport): ) if response_status != 200: - raise AuthenticationException( + # 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) -> Any: + async def perform_handshake(self) -> None: """Perform handshake1 and handshake2. Sets the encryption_session if successful. @@ -298,13 +292,20 @@ class KlapTransport(BaseTransport): self._session_cookie = None local_seed, remote_seed, auth_hash = await self.perform_handshake1() - self._session_cookie = self._http_client.cookies.get( # type: ignore - self.SESSION_COOKIE_NAME - ) + 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 - - self._session_expire_at = time.time() + 86400 + 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 ) @@ -312,14 +313,14 @@ class KlapTransport(BaseTransport): _LOGGER.debug("Handshake with %s complete", self._host) - def _handshake_session_expired(self): + 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 + or self._session_expire_at - time.monotonic() <= 0 ) - async def send(self, request: str): + 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() @@ -328,60 +329,61 @@ class KlapTransport(BaseTransport): if self._encryption_session is not None: payload, seq = self._encryption_session.encrypt(request.encode()) - url = f"http://{self._host}/app/request" - - response_status, response_data = await self.client_post( - url, + response_status, response_data = await self._http_client.post( + self._request_url, params={"seq": seq}, data=payload, + cookies_dict=self._session_cookie, ) msg = ( - f"at {datetime.datetime.now()}. Host is {self._host}, " + 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 succesful authentication " + msg) + _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 AuthenticationException( - f"Got a security error from {self._host} after handshake " - + "completed" + raise _RetryableError( + "Got a security error from %s after handshake completed", self._host ) else: - raise SmartDeviceException( - f"Device {self._host} responded with {response_status} to" - + f"request with seq {seq}" + raise KasaException( + f"Device {self._host} responded with {response_status} to " + f"request with seq {seq}" ) else: - _LOGGER.debug("Query posted " + msg) + _LOGGER.debug("Device %s query posted %s", self._host, msg) - # Check for mypy - if self._encryption_session is not None: + 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( - "%s << %s", - self._host, - _LOGGER.isEnabledFor(logging.DEBUG) and pf(json_payload), - ) + _LOGGER.debug("Device %s query response received", self._host) return json_payload async def close(self) -> None: - """Close the transport.""" - client = self._default_http_client - self._default_http_client = 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 - if client: - await client.aclose() @staticmethod - def generate_auth_hash(creds: Credentials): + 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 @@ -391,19 +393,19 @@ class KlapTransport(BaseTransport): @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): + def generate_owner_hash(creds: Credentials) -> bytes: """Return the MD5 hash of the username in this object.""" un = creds.username return md5(un.encode()) @@ -413,7 +415,7 @@ class KlapTransportV2(KlapTransport): """Implementation of the KLAP encryption protocol with v2 hanshake hashes.""" @staticmethod - def generate_auth_hash(creds: Credentials): + 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 @@ -423,14 +425,14 @@ class KlapTransportV2(KlapTransport): @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) @@ -441,19 +443,26 @@ class KlapEncryptionSession: i.e. sequence number which the device expects to increment. """ - def __init__(self, local_seed, remote_seed, user_hash): + _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, 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, remote_seed, user_hash): + 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 @@ -461,40 +470,38 @@ class KlapEncryptionSession: seq = int.from_bytes(fulliv[-4:], "big", signed=True) return (fulliv[:12], seq) - def _sig_derive(self, local_seed, remote_seed, user_hash): + 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 _iv_seq(self): - seq = self._seq.to_bytes(4, "big", signed=True) - iv = self._iv + seq - return iv + 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): + def encrypt(self, msg: bytes | str) -> tuple[bytes, int]: """Encrypt the data and increment the sequence number.""" - self._seq = self._seq + 1 + self._seq += 1 + self._generate_cipher() + if isinstance(msg, str): msg = msg.encode("utf-8") - cipher = Cipher(algorithms.AES(self._key), modes.CBC(self._iv_seq())) - encryptor = cipher.encryptor() + encryptor = self._cipher.encryptor() padder = padding.PKCS7(128).padder() padded_data = padder.update(msg) + padder.finalize() ciphertext = encryptor.update(padded_data) + encryptor.finalize() - - digest = hashes.Hash(hashes.SHA256()) - digest.update( - self._sig + self._seq.to_bytes(4, "big", signed=True) + ciphertext - ) - signature = digest.finalize() - + signature = hashlib.sha256( + self._sig + PACK_SIGNED_LONG(self._seq) + ciphertext + ).digest() return (signature + ciphertext, self._seq) - def decrypt(self, msg): + def decrypt(self, msg: bytes) -> str: """Decrypt the data.""" - cipher = Cipher(algorithms.AES(self._key), modes.CBC(self._iv_seq())) - decryptor = cipher.decryptor() + decryptor = self._cipher.decryptor() dp = decryptor.update(msg[32:]) + decryptor.finalize() unpadder = padding.PKCS7(128).unpadder() plaintextbytes = unpadder.update(dp) + unpadder.finalize() diff --git a/kasa/transports/linkietransport.py b/kasa/transports/linkietransport.py new file mode 100644 index 00000000..779d182e --- /dev/null +++ b/kasa/transports/linkietransport.py @@ -0,0 +1,143 @@ +"""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.""" + 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 00000000..2061d293 --- /dev/null +++ b/kasa/transports/sslaestransport.py @@ -0,0 +1,478 @@ +"""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 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( + [ + "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() + self._default_credentials: Credentials = get_default_credentials( + DEFAULT_CREDENTIALS["TAPOCAMERA"] + ) + 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 + + _LOGGER.debug("Created AES transport for %s", self._host) + + @property + def default_port(self) -> int: + """Default port for the transport.""" + 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 _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 + + 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 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) + assert self._encryption_session is not None + + if "result" in resp_dict and "response" in resp_dict["result"]: + raw_response: str = resp_dict["result"]["response"] + 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] + + @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.""" + local_nonce, server_nonce, pwd_hash = await self.perform_handshake1() + await self.perform_handshake2(local_nonce, server_nonce, pwd_hash) + + 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 ...") + + async def perform_handshake1(self) -> tuple[str, str, str]: + """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) + + # 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", {}) + ): + local_nonce = secrets.token_bytes(8).hex().upper() + default_resp_dict = await self.try_send_handshake1( + self._default_credentials.username, local_nonce + ) + if ( + default_error_code := self._get_response_error(default_resp_dict) + ) is SmartErrorCode.INVALID_NONCE and "nonce" in default_resp_dict[ + "result" + ].get("data", {}): + _LOGGER.debug("Connected to {self._host} with default username") + self._username = self._default_credentials.username + error_code = default_error_code + resp_dict = default_resp_dict + + if not self._username: + raise AuthenticationError( + f"Credentials must be supplied to connect to {self._host}" + ) + if error_code is not SmartErrorCode.INVALID_NONCE or ( + resp_dict and "nonce" not in resp_dict["result"].get("data", {}) + ): + 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"] + if self._credentials and self._credentials != Credentials(): + pwd_hash = _sha256_hash(self._credentials.password.encode()) + elif self._username and self._password: + pwd_hash = _sha256_hash(self._password.encode()) + else: + pwd_hash = _sha256_hash(self._default_credentials.password.encode()) + + 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._credentials.password.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"Server response doesn't match our challenge on ip {self._host}" + _LOGGER.debug(msg) + raise AuthenticationError(msg) + + async def try_send_handshake1(self, username: str, local_nonce: str) -> dict: + """Perform the handshake.""" + _LOGGER.debug("Will to send handshake1...") + + 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: %s", 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() + + 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 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 00000000..5ffc935f --- /dev/null +++ b/kasa/transports/ssltransport.py @@ -0,0 +1,233 @@ +"""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.""" + 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.info("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 00000000..77a232f0 --- /dev/null +++ b/kasa/transports/xortransport.py @@ -0,0 +1,231 @@ +"""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.json import loads as json_loads + +from .basetransport import BaseTransport + +_LOGGER = logging.getLogger(__name__) +_NO_RETRY_ERRORS = {errno.EHOSTDOWN, errno.EHOSTUNREACH, 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 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:" + f" {self._host}:{self._port}: {ex}" + ) from ex + else: + raise _RetryableError( + f"Unable to connect to the device:" + f" {self._host}:{self._port}: {ex}" + ) from ex + except Exception as ex: + await self.reset() + raise _RetryableError( + f"Unable to connect to the device:" f" {self._host}:{self._port}: {ex}" + ) 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 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 4224f188..00000000 --- a/poetry.lock +++ /dev/null @@ -1,1677 +0,0 @@ -# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. - -[[package]] -name = "alabaster" -version = "0.7.13" -description = "A configurable sidebar-enabled Sphinx theme" -optional = true -python-versions = ">=3.6" -files = [ - {file = "alabaster-0.7.13-py3-none-any.whl", hash = "sha256:1ee19aca801bbabb5ba3f5f258e4422dfa86f82f3e9cefb0859b283cdd7f62a3"}, - {file = "alabaster-0.7.13.tar.gz", hash = "sha256:a27a4a084d5e690e16e01e03ad2b2e552c61a65469419b907243193de1a84ae2"}, -] - -[[package]] -name = "annotated-types" -version = "0.5.0" -description = "Reusable constraint types to use with typing.Annotated" -optional = false -python-versions = ">=3.7" -files = [ - {file = "annotated_types-0.5.0-py3-none-any.whl", hash = "sha256:58da39888f92c276ad970249761ebea80ba544b77acddaa1a4d6cf78287d45fd"}, - {file = "annotated_types-0.5.0.tar.gz", hash = "sha256:47cdc3490d9ac1506ce92c7aaa76c579dc3509ff11e098fc867e5130ab7be802"}, -] - -[package.dependencies] -typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.9\""} - -[[package]] -name = "anyio" -version = "3.7.1" -description = "High level compatibility layer for multiple asynchronous event loop implementations" -optional = false -python-versions = ">=3.7" -files = [ - {file = "anyio-3.7.1-py3-none-any.whl", hash = "sha256:91dee416e570e92c64041bd18b900d1d6fa78dff7048769ce5ac5ddad004fbb5"}, - {file = "anyio-3.7.1.tar.gz", hash = "sha256:44a3c9aba0f5defa43261a8b3efb97891f2bd7d804e0e1f56419befa1adfc780"}, -] - -[package.dependencies] -exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} -idna = ">=2.8" -sniffio = ">=1.1" - -[package.extras] -doc = ["Sphinx", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme (>=1.2.2)", "sphinxcontrib-jquery"] -test = ["anyio[trio]", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] -trio = ["trio (<0.22)"] - -[[package]] -name = "async-timeout" -version = "4.0.3" -description = "Timeout context manager for asyncio programs" -optional = false -python-versions = ">=3.7" -files = [ - {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, - {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, -] - -[[package]] -name = "asyncclick" -version = "8.1.3.4" -description = "Composable command line interface toolkit, async version" -optional = false -python-versions = ">=3.7" -files = [ - {file = "asyncclick-8.1.3.4-py3-none-any.whl", hash = "sha256:f8db604e37dabd43922d58f857817b1dfd8f88695b75c4cc1afe7ff1cc238a7b"}, - {file = "asyncclick-8.1.3.4.tar.gz", hash = "sha256:81d98cbf6c8813f9cd5599f586d56cfc532e9e6441391974d10827abb90fe833"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - -[[package]] -name = "babel" -version = "2.12.1" -description = "Internationalization utilities" -optional = true -python-versions = ">=3.7" -files = [ - {file = "Babel-2.12.1-py3-none-any.whl", hash = "sha256:b4246fb7677d3b98f501a39d43396d3cafdc8eadb045f4a31be01863f655c610"}, - {file = "Babel-2.12.1.tar.gz", hash = "sha256:cc2d99999cd01d44420ae725a21c9e3711b3aadc7976d6147f622d8581963455"}, -] - -[package.dependencies] -pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} - -[[package]] -name = "cachetools" -version = "5.3.1" -description = "Extensible memoizing collections and decorators" -optional = false -python-versions = ">=3.7" -files = [ - {file = "cachetools-5.3.1-py3-none-any.whl", hash = "sha256:95ef631eeaea14ba2e36f06437f36463aac3a096799e876ee55e5cdccb102590"}, - {file = "cachetools-5.3.1.tar.gz", hash = "sha256:dce83f2d9b4e1f732a8cd44af8e8fab2dbe46201467fc98b3ef8f269092bf62b"}, -] - -[[package]] -name = "certifi" -version = "2023.7.22" -description = "Python package for providing Mozilla's CA Bundle." -optional = false -python-versions = ">=3.6" -files = [ - {file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"}, - {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, -] - -[[package]] -name = "cffi" -version = "1.15.1" -description = "Foreign Function Interface for Python calling C code." -optional = false -python-versions = "*" -files = [ - {file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"}, - {file = "cffi-1.15.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2"}, - {file = "cffi-1.15.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914"}, - {file = "cffi-1.15.1-cp27-cp27m-win32.whl", hash = "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3"}, - {file = "cffi-1.15.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e"}, - {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162"}, - {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b"}, - {file = "cffi-1.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21"}, - {file = "cffi-1.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4"}, - {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01"}, - {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e"}, - {file = "cffi-1.15.1-cp310-cp310-win32.whl", hash = "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2"}, - {file = "cffi-1.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d"}, - {file = "cffi-1.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac"}, - {file = "cffi-1.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83"}, - {file = "cffi-1.15.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9"}, - {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c"}, - {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325"}, - {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c"}, - {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef"}, - {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8"}, - {file = "cffi-1.15.1-cp311-cp311-win32.whl", hash = "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d"}, - {file = "cffi-1.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104"}, - {file = "cffi-1.15.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7"}, - {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6"}, - {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d"}, - {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a"}, - {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405"}, - {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e"}, - {file = "cffi-1.15.1-cp36-cp36m-win32.whl", hash = "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf"}, - {file = "cffi-1.15.1-cp36-cp36m-win_amd64.whl", hash = "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497"}, - {file = "cffi-1.15.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375"}, - {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e"}, - {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82"}, - {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b"}, - {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c"}, - {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426"}, - {file = "cffi-1.15.1-cp37-cp37m-win32.whl", hash = "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9"}, - {file = "cffi-1.15.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045"}, - {file = "cffi-1.15.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3"}, - {file = "cffi-1.15.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a"}, - {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5"}, - {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca"}, - {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02"}, - {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192"}, - {file = "cffi-1.15.1-cp38-cp38-win32.whl", hash = "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314"}, - {file = "cffi-1.15.1-cp38-cp38-win_amd64.whl", hash = "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5"}, - {file = "cffi-1.15.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585"}, - {file = "cffi-1.15.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27"}, - {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76"}, - {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3"}, - {file = "cffi-1.15.1-cp39-cp39-win32.whl", hash = "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee"}, - {file = "cffi-1.15.1-cp39-cp39-win_amd64.whl", hash = "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c"}, - {file = "cffi-1.15.1.tar.gz", hash = "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9"}, -] - -[package.dependencies] -pycparser = "*" - -[[package]] -name = "cfgv" -version = "3.4.0" -description = "Validate configuration and produce human readable error messages." -optional = false -python-versions = ">=3.8" -files = [ - {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, - {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, -] - -[[package]] -name = "chardet" -version = "5.2.0" -description = "Universal encoding detector for Python 3" -optional = false -python-versions = ">=3.7" -files = [ - {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"}, - {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"}, -] - -[[package]] -name = "charset-normalizer" -version = "3.2.0" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -optional = false -python-versions = ">=3.7.0" -files = [ - {file = "charset-normalizer-3.2.0.tar.gz", hash = "sha256:3bb3d25a8e6c0aedd251753a79ae98a093c7e7b471faa3aa9a93a81431987ace"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b87549028f680ca955556e3bd57013ab47474c3124dc069faa0b6545b6c9710"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7c70087bfee18a42b4040bb9ec1ca15a08242cf5867c58726530bdf3945672ed"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a103b3a7069b62f5d4890ae1b8f0597618f628b286b03d4bc9195230b154bfa9"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94aea8eff76ee6d1cdacb07dd2123a68283cb5569e0250feab1240058f53b623"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:db901e2ac34c931d73054d9797383d0f8009991e723dab15109740a63e7f902a"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b0dac0ff919ba34d4df1b6131f59ce95b08b9065233446be7e459f95554c0dc8"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:193cbc708ea3aca45e7221ae58f0fd63f933753a9bfb498a3b474878f12caaad"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09393e1b2a9461950b1c9a45d5fd251dc7c6f228acab64da1c9c0165d9c7765c"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:baacc6aee0b2ef6f3d308e197b5d7a81c0e70b06beae1f1fcacffdbd124fe0e3"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:bf420121d4c8dce6b889f0e8e4ec0ca34b7f40186203f06a946fa0276ba54029"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:c04a46716adde8d927adb9457bbe39cf473e1e2c2f5d0a16ceb837e5d841ad4f"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:aaf63899c94de41fe3cf934601b0f7ccb6b428c6e4eeb80da72c58eab077b19a"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d62e51710986674142526ab9f78663ca2b0726066ae26b78b22e0f5e571238dd"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-win32.whl", hash = "sha256:04e57ab9fbf9607b77f7d057974694b4f6b142da9ed4a199859d9d4d5c63fe96"}, - {file = "charset_normalizer-3.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:48021783bdf96e3d6de03a6e39a1171ed5bd7e8bb93fc84cc649d11490f87cea"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4957669ef390f0e6719db3613ab3a7631e68424604a7b448f079bee145da6e09"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46fb8c61d794b78ec7134a715a3e564aafc8f6b5e338417cb19fe9f57a5a9bf2"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f779d3ad205f108d14e99bb3859aa7dd8e9c68874617c72354d7ecaec2a054ac"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f25c229a6ba38a35ae6e25ca1264621cc25d4d38dca2942a7fce0b67a4efe918"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2efb1bd13885392adfda4614c33d3b68dee4921fd0ac1d3988f8cbb7d589e72a"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f30b48dd7fa1474554b0b0f3fdfdd4c13b5c737a3c6284d3cdc424ec0ffff3a"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:246de67b99b6851627d945db38147d1b209a899311b1305dd84916f2b88526c6"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bd9b3b31adcb054116447ea22caa61a285d92e94d710aa5ec97992ff5eb7cf3"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:8c2f5e83493748286002f9369f3e6607c565a6a90425a3a1fef5ae32a36d749d"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3170c9399da12c9dc66366e9d14da8bf7147e1e9d9ea566067bbce7bb74bd9c2"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:7a4826ad2bd6b07ca615c74ab91f32f6c96d08f6fcc3902ceeedaec8cdc3bcd6"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:3b1613dd5aee995ec6d4c69f00378bbd07614702a315a2cf6c1d21461fe17c23"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9e608aafdb55eb9f255034709e20d5a83b6d60c054df0802fa9c9883d0a937aa"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-win32.whl", hash = "sha256:f2a1d0fd4242bd8643ce6f98927cf9c04540af6efa92323e9d3124f57727bfc1"}, - {file = "charset_normalizer-3.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:681eb3d7e02e3c3655d1b16059fbfb605ac464c834a0c629048a30fad2b27489"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c57921cda3a80d0f2b8aec7e25c8aa14479ea92b5b51b6876d975d925a2ea346"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41b25eaa7d15909cf3ac4c96088c1f266a9a93ec44f87f1d13d4a0e86c81b982"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f058f6963fd82eb143c692cecdc89e075fa0828db2e5b291070485390b2f1c9c"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7647ebdfb9682b7bb97e2a5e7cb6ae735b1c25008a70b906aecca294ee96cf4"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eef9df1eefada2c09a5e7a40991b9fc6ac6ef20b1372abd48d2794a316dc0449"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e03b8895a6990c9ab2cdcd0f2fe44088ca1c65ae592b8f795c3294af00a461c3"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:ee4006268ed33370957f55bf2e6f4d263eaf4dc3cfc473d1d90baff6ed36ce4a"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c4983bf937209c57240cff65906b18bb35e64ae872da6a0db937d7b4af845dd7"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:3bb7fda7260735efe66d5107fb7e6af6a7c04c7fce9b2514e04b7a74b06bf5dd"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:72814c01533f51d68702802d74f77ea026b5ec52793c791e2da806a3844a46c3"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:70c610f6cbe4b9fce272c407dd9d07e33e6bf7b4aa1b7ffb6f6ded8e634e3592"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-win32.whl", hash = "sha256:a401b4598e5d3f4a9a811f3daf42ee2291790c7f9d74b18d75d6e21dda98a1a1"}, - {file = "charset_normalizer-3.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:c0b21078a4b56965e2b12f247467b234734491897e99c1d51cee628da9786959"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:95eb302ff792e12aba9a8b8f8474ab229a83c103d74a750ec0bd1c1eea32e669"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1a100c6d595a7f316f1b6f01d20815d916e75ff98c27a01ae817439ea7726329"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6339d047dab2780cc6220f46306628e04d9750f02f983ddb37439ca47ced7149"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4b749b9cc6ee664a3300bb3a273c1ca8068c46be705b6c31cf5d276f8628a94"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a38856a971c602f98472050165cea2cdc97709240373041b69030be15047691f"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f87f746ee241d30d6ed93969de31e5ffd09a2961a051e60ae6bddde9ec3583aa"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89f1b185a01fe560bc8ae5f619e924407efca2191b56ce749ec84982fc59a32a"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1c8a2f4c69e08e89632defbfabec2feb8a8d99edc9f89ce33c4b9e36ab63037"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2f4ac36d8e2b4cc1aa71df3dd84ff8efbe3bfb97ac41242fbcfc053c67434f46"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a386ebe437176aab38c041de1260cd3ea459c6ce5263594399880bbc398225b2"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:ccd16eb18a849fd8dcb23e23380e2f0a354e8daa0c984b8a732d9cfaba3a776d"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:e6a5bf2cba5ae1bb80b154ed68a3cfa2fa00fde979a7f50d6598d3e17d9ac20c"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:45de3f87179c1823e6d9e32156fb14c1927fcc9aba21433f088fdfb555b77c10"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-win32.whl", hash = "sha256:1000fba1057b92a65daec275aec30586c3de2401ccdcd41f8a5c1e2c87078706"}, - {file = "charset_normalizer-3.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:8b2c760cfc7042b27ebdb4a43a4453bd829a5742503599144d54a032c5dc7e9e"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:855eafa5d5a2034b4621c74925d89c5efef61418570e5ef9b37717d9c796419c"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:203f0c8871d5a7987be20c72442488a0b8cfd0f43b7973771640fc593f56321f"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e857a2232ba53ae940d3456f7533ce6ca98b81917d47adc3c7fd55dad8fab858"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e86d77b090dbddbe78867a0275cb4df08ea195e660f1f7f13435a4649e954e5"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4fb39a81950ec280984b3a44f5bd12819953dc5fa3a7e6fa7a80db5ee853952"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2dee8e57f052ef5353cf608e0b4c871aee320dd1b87d351c28764fc0ca55f9f4"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8700f06d0ce6f128de3ccdbc1acaea1ee264d2caa9ca05daaf492fde7c2a7200"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1920d4ff15ce893210c1f0c0e9d19bfbecb7983c76b33f046c13a8ffbd570252"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c1c76a1743432b4b60ab3358c937a3fe1341c828ae6194108a94c69028247f22"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f7560358a6811e52e9c4d142d497f1a6e10103d3a6881f18d04dbce3729c0e2c"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:c8063cf17b19661471ecbdb3df1c84f24ad2e389e326ccaf89e3fb2484d8dd7e"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:cd6dbe0238f7743d0efe563ab46294f54f9bc8f4b9bcf57c3c666cc5bc9d1299"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1249cbbf3d3b04902ff081ffbb33ce3377fa6e4c7356f759f3cd076cc138d020"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-win32.whl", hash = "sha256:6c409c0deba34f147f77efaa67b8e4bb83d2f11c8806405f76397ae5b8c0d1c9"}, - {file = "charset_normalizer-3.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:7095f6fbfaa55defb6b733cfeb14efaae7a29f0b59d8cf213be4e7ca0b857b80"}, - {file = "charset_normalizer-3.2.0-py3-none-any.whl", hash = "sha256:8e098148dd37b4ce3baca71fb394c81dc5d9c7728c95df695d2dca218edf40e6"}, -] - -[[package]] -name = "codecov" -version = "2.1.13" -description = "Hosted coverage reports for GitHub, Bitbucket and Gitlab" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -files = [ - {file = "codecov-2.1.13-py2.py3-none-any.whl", hash = "sha256:c2ca5e51bba9ebb43644c43d0690148a55086f7f5e6fd36170858fa4206744d5"}, - {file = "codecov-2.1.13.tar.gz", hash = "sha256:2362b685633caeaf45b9951a9b76ce359cd3581dd515b430c6c3f5dfb4d92a8c"}, -] - -[package.dependencies] -coverage = "*" -requests = ">=2.7.9" - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] - -[[package]] -name = "coverage" -version = "7.3.0" -description = "Code coverage measurement for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "coverage-7.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:db76a1bcb51f02b2007adacbed4c88b6dee75342c37b05d1822815eed19edee5"}, - {file = "coverage-7.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c02cfa6c36144ab334d556989406837336c1d05215a9bdf44c0bc1d1ac1cb637"}, - {file = "coverage-7.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:477c9430ad5d1b80b07f3c12f7120eef40bfbf849e9e7859e53b9c93b922d2af"}, - {file = "coverage-7.3.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce2ee86ca75f9f96072295c5ebb4ef2a43cecf2870b0ca5e7a1cbdd929cf67e1"}, - {file = "coverage-7.3.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68d8a0426b49c053013e631c0cdc09b952d857efa8f68121746b339912d27a12"}, - {file = "coverage-7.3.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b3eb0c93e2ea6445b2173da48cb548364f8f65bf68f3d090404080d338e3a689"}, - {file = "coverage-7.3.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:90b6e2f0f66750c5a1178ffa9370dec6c508a8ca5265c42fbad3ccac210a7977"}, - {file = "coverage-7.3.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:96d7d761aea65b291a98c84e1250cd57b5b51726821a6f2f8df65db89363be51"}, - {file = "coverage-7.3.0-cp310-cp310-win32.whl", hash = "sha256:63c5b8ecbc3b3d5eb3a9d873dec60afc0cd5ff9d9f1c75981d8c31cfe4df8527"}, - {file = "coverage-7.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:97c44f4ee13bce914272589b6b41165bbb650e48fdb7bd5493a38bde8de730a1"}, - {file = "coverage-7.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:74c160285f2dfe0acf0f72d425f3e970b21b6de04157fc65adc9fd07ee44177f"}, - {file = "coverage-7.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b543302a3707245d454fc49b8ecd2c2d5982b50eb63f3535244fd79a4be0c99d"}, - {file = "coverage-7.3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad0f87826c4ebd3ef484502e79b39614e9c03a5d1510cfb623f4a4a051edc6fd"}, - {file = "coverage-7.3.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:13c6cbbd5f31211d8fdb477f0f7b03438591bdd077054076eec362cf2207b4a7"}, - {file = "coverage-7.3.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fac440c43e9b479d1241fe9d768645e7ccec3fb65dc3a5f6e90675e75c3f3e3a"}, - {file = "coverage-7.3.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:3c9834d5e3df9d2aba0275c9f67989c590e05732439b3318fa37a725dff51e74"}, - {file = "coverage-7.3.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:4c8e31cf29b60859876474034a83f59a14381af50cbe8a9dbaadbf70adc4b214"}, - {file = "coverage-7.3.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7a9baf8e230f9621f8e1d00c580394a0aa328fdac0df2b3f8384387c44083c0f"}, - {file = "coverage-7.3.0-cp311-cp311-win32.whl", hash = "sha256:ccc51713b5581e12f93ccb9c5e39e8b5d4b16776d584c0f5e9e4e63381356482"}, - {file = "coverage-7.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:887665f00ea4e488501ba755a0e3c2cfd6278e846ada3185f42d391ef95e7e70"}, - {file = "coverage-7.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d000a739f9feed900381605a12a61f7aaced6beae832719ae0d15058a1e81c1b"}, - {file = "coverage-7.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:59777652e245bb1e300e620ce2bef0d341945842e4eb888c23a7f1d9e143c446"}, - {file = "coverage-7.3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9737bc49a9255d78da085fa04f628a310c2332b187cd49b958b0e494c125071"}, - {file = "coverage-7.3.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5247bab12f84a1d608213b96b8af0cbb30d090d705b6663ad794c2f2a5e5b9fe"}, - {file = "coverage-7.3.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2ac9a1de294773b9fa77447ab7e529cf4fe3910f6a0832816e5f3d538cfea9a"}, - {file = "coverage-7.3.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:85b7335c22455ec12444cec0d600533a238d6439d8d709d545158c1208483873"}, - {file = "coverage-7.3.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:36ce5d43a072a036f287029a55b5c6a0e9bd73db58961a273b6dc11a2c6eb9c2"}, - {file = "coverage-7.3.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:211a4576e984f96d9fce61766ffaed0115d5dab1419e4f63d6992b480c2bd60b"}, - {file = "coverage-7.3.0-cp312-cp312-win32.whl", hash = "sha256:56afbf41fa4a7b27f6635bc4289050ac3ab7951b8a821bca46f5b024500e6321"}, - {file = "coverage-7.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f297e0c1ae55300ff688568b04ff26b01c13dfbf4c9d2b7d0cb688ac60df479"}, - {file = "coverage-7.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ac0dec90e7de0087d3d95fa0533e1d2d722dcc008bc7b60e1143402a04c117c1"}, - {file = "coverage-7.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:438856d3f8f1e27f8e79b5410ae56650732a0dcfa94e756df88c7e2d24851fcd"}, - {file = "coverage-7.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1084393c6bda8875c05e04fce5cfe1301a425f758eb012f010eab586f1f3905e"}, - {file = "coverage-7.3.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49ab200acf891e3dde19e5aa4b0f35d12d8b4bd805dc0be8792270c71bd56c54"}, - {file = "coverage-7.3.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a67e6bbe756ed458646e1ef2b0778591ed4d1fcd4b146fc3ba2feb1a7afd4254"}, - {file = "coverage-7.3.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8f39c49faf5344af36042b293ce05c0d9004270d811c7080610b3e713251c9b0"}, - {file = "coverage-7.3.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7df91fb24c2edaabec4e0eee512ff3bc6ec20eb8dccac2e77001c1fe516c0c84"}, - {file = "coverage-7.3.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:34f9f0763d5fa3035a315b69b428fe9c34d4fc2f615262d6be3d3bf3882fb985"}, - {file = "coverage-7.3.0-cp38-cp38-win32.whl", hash = "sha256:bac329371d4c0d456e8d5f38a9b0816b446581b5f278474e416ea0c68c47dcd9"}, - {file = "coverage-7.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:b859128a093f135b556b4765658d5d2e758e1fae3e7cc2f8c10f26fe7005e543"}, - {file = "coverage-7.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fc0ed8d310afe013db1eedd37176d0839dc66c96bcfcce8f6607a73ffea2d6ba"}, - {file = "coverage-7.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61260ec93f99f2c2d93d264b564ba912bec502f679793c56f678ba5251f0393"}, - {file = "coverage-7.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97af9554a799bd7c58c0179cc8dbf14aa7ab50e1fd5fa73f90b9b7215874ba28"}, - {file = "coverage-7.3.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3558e5b574d62f9c46b76120a5c7c16c4612dc2644c3d48a9f4064a705eaee95"}, - {file = "coverage-7.3.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37d5576d35fcb765fca05654f66aa71e2808d4237d026e64ac8b397ffa66a56a"}, - {file = "coverage-7.3.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:07ea61bcb179f8f05ffd804d2732b09d23a1238642bf7e51dad62082b5019b34"}, - {file = "coverage-7.3.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:80501d1b2270d7e8daf1b64b895745c3e234289e00d5f0e30923e706f110334e"}, - {file = "coverage-7.3.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4eddd3153d02204f22aef0825409091a91bf2a20bce06fe0f638f5c19a85de54"}, - {file = "coverage-7.3.0-cp39-cp39-win32.whl", hash = "sha256:2d22172f938455c156e9af2612650f26cceea47dc86ca048fa4e0b2d21646ad3"}, - {file = "coverage-7.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:60f64e2007c9144375dd0f480a54d6070f00bb1a28f65c408370544091c9bc9e"}, - {file = "coverage-7.3.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:5492a6ce3bdb15c6ad66cb68a0244854d9917478877a25671d70378bdc8562d0"}, - {file = "coverage-7.3.0.tar.gz", hash = "sha256:49dbb19cdcafc130f597d9e04a29d0a032ceedf729e41b181f51cd170e6ee865"}, -] - -[package.dependencies] -tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} - -[package.extras] -toml = ["tomli"] - -[[package]] -name = "cryptography" -version = "41.0.2" -description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." -optional = false -python-versions = ">=3.7" -files = [ - {file = "cryptography-41.0.2-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:01f1d9e537f9a15b037d5d9ee442b8c22e3ae11ce65ea1f3316a41c78756b711"}, - {file = "cryptography-41.0.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:079347de771f9282fbfe0e0236c716686950c19dee1b76240ab09ce1624d76d7"}, - {file = "cryptography-41.0.2-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:439c3cc4c0d42fa999b83ded80a9a1fb54d53c58d6e59234cfe97f241e6c781d"}, - {file = "cryptography-41.0.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f14ad275364c8b4e525d018f6716537ae7b6d369c094805cae45300847e0894f"}, - {file = "cryptography-41.0.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:84609ade00a6ec59a89729e87a503c6e36af98ddcd566d5f3be52e29ba993182"}, - {file = "cryptography-41.0.2-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:49c3222bb8f8e800aead2e376cbef687bc9e3cb9b58b29a261210456a7783d83"}, - {file = "cryptography-41.0.2-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:d73f419a56d74fef257955f51b18d046f3506270a5fd2ac5febbfa259d6c0fa5"}, - {file = "cryptography-41.0.2-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:2a034bf7d9ca894720f2ec1d8b7b5832d7e363571828037f9e0c4f18c1b58a58"}, - {file = "cryptography-41.0.2-cp37-abi3-win32.whl", hash = "sha256:d124682c7a23c9764e54ca9ab5b308b14b18eba02722b8659fb238546de83a76"}, - {file = "cryptography-41.0.2-cp37-abi3-win_amd64.whl", hash = "sha256:9c3fe6534d59d071ee82081ca3d71eed3210f76ebd0361798c74abc2bcf347d4"}, - {file = "cryptography-41.0.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a719399b99377b218dac6cf547b6ec54e6ef20207b6165126a280b0ce97e0d2a"}, - {file = "cryptography-41.0.2-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:182be4171f9332b6741ee818ec27daff9fb00349f706629f5cbf417bd50e66fd"}, - {file = "cryptography-41.0.2-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:7a9a3bced53b7f09da251685224d6a260c3cb291768f54954e28f03ef14e3766"}, - {file = "cryptography-41.0.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:f0dc40e6f7aa37af01aba07277d3d64d5a03dc66d682097541ec4da03cc140ee"}, - {file = "cryptography-41.0.2-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:674b669d5daa64206c38e507808aae49904c988fa0a71c935e7006a3e1e83831"}, - {file = "cryptography-41.0.2-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7af244b012711a26196450d34f483357e42aeddb04128885d95a69bd8b14b69b"}, - {file = "cryptography-41.0.2-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9b6d717393dbae53d4e52684ef4f022444fc1cce3c48c38cb74fca29e1f08eaa"}, - {file = "cryptography-41.0.2-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:192255f539d7a89f2102d07d7375b1e0a81f7478925b3bc2e0549ebf739dae0e"}, - {file = "cryptography-41.0.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f772610fe364372de33d76edcd313636a25684edb94cee53fd790195f5989d14"}, - {file = "cryptography-41.0.2-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:b332cba64d99a70c1e0836902720887fb4529ea49ea7f5462cf6640e095e11d2"}, - {file = "cryptography-41.0.2-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9a6673c1828db6270b76b22cc696f40cde9043eb90373da5c2f8f2158957f42f"}, - {file = "cryptography-41.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:342f3767e25876751e14f8459ad85e77e660537ca0a066e10e75df9c9e9099f0"}, - {file = "cryptography-41.0.2.tar.gz", hash = "sha256:7d230bf856164de164ecb615ccc14c7fc6de6906ddd5b491f3af90d3514c925c"}, -] - -[package.dependencies] -cffi = ">=1.12" - -[package.extras] -docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] -docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"] -nox = ["nox"] -pep8test = ["black", "check-sdist", "mypy", "ruff"] -sdist = ["build"] -ssh = ["bcrypt (>=3.1.5)"] -test = ["pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] -test-randomorder = ["pytest-randomly"] - -[[package]] -name = "distlib" -version = "0.3.7" -description = "Distribution utilities" -optional = false -python-versions = "*" -files = [ - {file = "distlib-0.3.7-py2.py3-none-any.whl", hash = "sha256:2e24928bc811348f0feb63014e97aaae3037f2cf48712d51ae61df7fd6075057"}, - {file = "distlib-0.3.7.tar.gz", hash = "sha256:9dafe54b34a028eafd95039d5e5d4851a13734540f1331060d31c9916e7147a8"}, -] - -[[package]] -name = "docutils" -version = "0.17.1" -description = "Docutils -- Python Documentation Utilities" -optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -files = [ - {file = "docutils-0.17.1-py2.py3-none-any.whl", hash = "sha256:cf316c8370a737a022b72b56874f6602acf974a37a9fba42ec2876387549fc61"}, - {file = "docutils-0.17.1.tar.gz", hash = "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125"}, -] - -[[package]] -name = "exceptiongroup" -version = "1.1.3" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -files = [ - {file = "exceptiongroup-1.1.3-py3-none-any.whl", hash = "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3"}, - {file = "exceptiongroup-1.1.3.tar.gz", hash = "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9"}, -] - -[package.extras] -test = ["pytest (>=6)"] - -[[package]] -name = "filelock" -version = "3.12.2" -description = "A platform independent file lock." -optional = false -python-versions = ">=3.7" -files = [ - {file = "filelock-3.12.2-py3-none-any.whl", hash = "sha256:cbb791cdea2a72f23da6ac5b5269ab0a0d161e9ef0100e653b69049a7706d1ec"}, - {file = "filelock-3.12.2.tar.gz", hash = "sha256:002740518d8aa59a26b0c76e10fb8c6e15eae825d34b6fdf670333fd7b938d81"}, -] - -[package.extras] -docs = ["furo (>=2023.5.20)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "diff-cover (>=7.5)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)"] - -[[package]] -name = "h11" -version = "0.14.0" -description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -optional = false -python-versions = ">=3.7" -files = [ - {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, - {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, -] - -[[package]] -name = "httpcore" -version = "1.0.1" -description = "A minimal low-level HTTP client." -optional = false -python-versions = ">=3.8" -files = [ - {file = "httpcore-1.0.1-py3-none-any.whl", hash = "sha256:c5e97ef177dca2023d0b9aad98e49507ef5423e9f1d94ffe2cfe250aa28e63b0"}, - {file = "httpcore-1.0.1.tar.gz", hash = "sha256:fce1ddf9b606cfb98132ab58865c3728c52c8e4c3c46e2aabb3674464a186e92"}, -] - -[package.dependencies] -certifi = "*" -h11 = ">=0.13,<0.15" - -[package.extras] -asyncio = ["anyio (>=4.0,<5.0)"] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (==1.*)"] -trio = ["trio (>=0.22.0,<0.23.0)"] - -[[package]] -name = "httpx" -version = "0.25.1" -description = "The next generation HTTP client." -optional = false -python-versions = ">=3.8" -files = [ - {file = "httpx-0.25.1-py3-none-any.whl", hash = "sha256:fec7d6cc5c27c578a391f7e87b9aa7d3d8fbcd034f6399f9f79b45bcc12a866a"}, - {file = "httpx-0.25.1.tar.gz", hash = "sha256:ffd96d5cf901e63863d9f1b4b6807861dbea4d301613415d9e6e57ead15fc5d0"}, -] - -[package.dependencies] -anyio = "*" -certifi = "*" -httpcore = "*" -idna = "*" -sniffio = "*" - -[package.extras] -brotli = ["brotli", "brotlicffi"] -cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] -http2 = ["h2 (>=3,<5)"] -socks = ["socksio (==1.*)"] - -[[package]] -name = "identify" -version = "2.5.27" -description = "File identification library for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "identify-2.5.27-py2.py3-none-any.whl", hash = "sha256:fdb527b2dfe24602809b2201e033c2a113d7bdf716db3ca8e3243f735dcecaba"}, - {file = "identify-2.5.27.tar.gz", hash = "sha256:287b75b04a0e22d727bc9a41f0d4f3c1bcada97490fa6eabb5b28f0e9097e733"}, -] - -[package.extras] -license = ["ukkonen"] - -[[package]] -name = "idna" -version = "3.4" -description = "Internationalized Domain Names in Applications (IDNA)" -optional = false -python-versions = ">=3.5" -files = [ - {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, - {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, -] - -[[package]] -name = "imagesize" -version = "1.4.1" -description = "Getting image size from png/jpeg/jpeg2000/gif file" -optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -files = [ - {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, - {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, -] - -[[package]] -name = "importlib-metadata" -version = "6.8.0" -description = "Read metadata from Python packages" -optional = true -python-versions = ">=3.8" -files = [ - {file = "importlib_metadata-6.8.0-py3-none-any.whl", hash = "sha256:3ebb78df84a805d7698245025b975d9d67053cd94c79245ba4b3eb694abe68bb"}, - {file = "importlib_metadata-6.8.0.tar.gz", hash = "sha256:dbace7892d8c0c4ac1ad096662232f831d4e64f4c4545bd53016a3e9d4654743"}, -] - -[package.dependencies] -zipp = ">=0.5" - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -perf = ["ipython"] -testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] - -[[package]] -name = "iniconfig" -version = "2.0.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.7" -files = [ - {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, - {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, -] - -[[package]] -name = "jinja2" -version = "3.1.2" -description = "A very fast and expressive template engine." -optional = true -python-versions = ">=3.7" -files = [ - {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, - {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, -] - -[package.dependencies] -MarkupSafe = ">=2.0" - -[package.extras] -i18n = ["Babel (>=2.7)"] - -[[package]] -name = "kasa-crypt" -version = "0.3.0" -description = "Fast kasa crypt" -optional = true -python-versions = ">=3.7,<4.0" -files = [ - {file = "kasa_crypt-0.3.0-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:17f543d6952d3cd8aa094429870f9e3241f6035df2ecfd1b937cd6e7da5902c6"}, - {file = "kasa_crypt-0.3.0-cp310-cp310-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:4a6b15fd4832051b5f75db1eec8c273ba6e5a3122cd7030e0f92d0a90babc5ed"}, - {file = "kasa_crypt-0.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa1558b81cb36be015f211d88c69ead8f8708add1206e89672ffc7f06449c682"}, - {file = "kasa_crypt-0.3.0-cp310-cp310-manylinux_2_31_x86_64.whl", hash = "sha256:e7525a9770b0df0cde5f2b764dc5415eb5f136159477ffc85759f9dba21a1aff"}, - {file = "kasa_crypt-0.3.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:dfa84ee1449939d04e5e4a1c6931a2d429f7c1236a6c99eb3970afdf4723fe76"}, - {file = "kasa_crypt-0.3.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c7e9f3852f087bc5af2077aa95c31a96a6e2a1f89198a4474dd641e578cd1086"}, - {file = "kasa_crypt-0.3.0-cp310-cp310-win32.whl", hash = "sha256:da1f03dcc12261c10ae8c65bb02d809273ecdf1fc31a9dba58af1ae70cae970e"}, - {file = "kasa_crypt-0.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:ccd10995596e746521a6c7be6ca39a87fae74ddd46558f1d6eea5ab221791107"}, - {file = "kasa_crypt-0.3.0-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:acc3fbb9adf7b80c310cf4bd7334d8bea8e19478b3a24447064093091acef93f"}, - {file = "kasa_crypt-0.3.0-cp311-cp311-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:34ea41e7788062fc782bcfbe998f8c8d75308785e50c4e3f338dd4c2e488881f"}, - {file = "kasa_crypt-0.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2f5231874d7973036b7afce432bb5b7404cdafbbb4d46363580aafcc5d26fde5"}, - {file = "kasa_crypt-0.3.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:17c7938af96b30416eac6899689e9126c1d17f8f9a0f9dcf9f5cda86f084c60d"}, - {file = "kasa_crypt-0.3.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c497dbaf1a76b190d025753f146af3e8c948d037b7ca293f4eeebb9f9721f4f8"}, - {file = "kasa_crypt-0.3.0-cp311-cp311-win32.whl", hash = "sha256:0ede1c2e460e8481705a159e13b6e437fa09ac24993e4a55edc26a962ffa436e"}, - {file = "kasa_crypt-0.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:90f6c66b48db56631e7fe391f4e4934b0ddf6f41d31aa834c1baedc6c94b40d4"}, - {file = "kasa_crypt-0.3.0.tar.gz", hash = "sha256:80c866a1f5d4ad419fcd454b2343a6ecfff8814195ab2caf108941971150ccd8"}, -] - -[[package]] -name = "markdown-it-py" -version = "2.2.0" -description = "Python port of markdown-it. Markdown parsing, done right!" -optional = true -python-versions = ">=3.7" -files = [ - {file = "markdown-it-py-2.2.0.tar.gz", hash = "sha256:7c9a5e412688bc771c67432cbfebcdd686c93ce6484913dccf06cb5a0bea35a1"}, - {file = "markdown_it_py-2.2.0-py3-none-any.whl", hash = "sha256:5a35f8d1870171d9acc47b99612dc146129b631baf04970128b568f190d0cc30"}, -] - -[package.dependencies] -mdurl = ">=0.1,<1.0" - -[package.extras] -benchmarking = ["psutil", "pytest", "pytest-benchmark"] -code-style = ["pre-commit (>=3.0,<4.0)"] -compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] -linkify = ["linkify-it-py (>=1,<3)"] -plugins = ["mdit-py-plugins"] -profiling = ["gprof2dot"] -rtd = ["attrs", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] -testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] - -[[package]] -name = "markupsafe" -version = "2.1.3" -description = "Safely add untrusted strings to HTML/XML markup." -optional = true -python-versions = ">=3.7" -files = [ - {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-win32.whl", hash = "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431"}, - {file = "MarkupSafe-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, - {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, - {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-win32.whl", hash = "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0"}, - {file = "MarkupSafe-2.1.3-cp37-cp37m-win_amd64.whl", hash = "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-win32.whl", hash = "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5"}, - {file = "MarkupSafe-2.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-win32.whl", hash = "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2"}, - {file = "MarkupSafe-2.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba"}, - {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, -] - -[[package]] -name = "mdit-py-plugins" -version = "0.3.5" -description = "Collection of plugins for markdown-it-py" -optional = true -python-versions = ">=3.7" -files = [ - {file = "mdit-py-plugins-0.3.5.tar.gz", hash = "sha256:eee0adc7195e5827e17e02d2a258a2ba159944a0748f59c5099a4a27f78fcf6a"}, - {file = "mdit_py_plugins-0.3.5-py3-none-any.whl", hash = "sha256:ca9a0714ea59a24b2b044a1831f48d817dd0c817e84339f20e7889f392d77c4e"}, -] - -[package.dependencies] -markdown-it-py = ">=1.0.0,<3.0.0" - -[package.extras] -code-style = ["pre-commit"] -rtd = ["attrs", "myst-parser (>=0.16.1,<0.17.0)", "sphinx-book-theme (>=0.1.0,<0.2.0)"] -testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] - -[[package]] -name = "mdurl" -version = "0.1.2" -description = "Markdown URL utilities" -optional = true -python-versions = ">=3.7" -files = [ - {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, - {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, -] - -[[package]] -name = "myst-parser" -version = "0.18.1" -description = "An extended commonmark compliant parser, with bridges to docutils & sphinx." -optional = true -python-versions = ">=3.7" -files = [ - {file = "myst-parser-0.18.1.tar.gz", hash = "sha256:79317f4bb2c13053dd6e64f9da1ba1da6cd9c40c8a430c447a7b146a594c246d"}, - {file = "myst_parser-0.18.1-py3-none-any.whl", hash = "sha256:61b275b85d9f58aa327f370913ae1bec26ebad372cc99f3ab85c8ec3ee8d9fb8"}, -] - -[package.dependencies] -docutils = ">=0.15,<0.20" -jinja2 = "*" -markdown-it-py = ">=1.0.0,<3.0.0" -mdit-py-plugins = ">=0.3.1,<0.4.0" -pyyaml = "*" -sphinx = ">=4,<6" -typing-extensions = "*" - -[package.extras] -code-style = ["pre-commit (>=2.12,<3.0)"] -linkify = ["linkify-it-py (>=1.0,<2.0)"] -rtd = ["ipython", "sphinx-book-theme", "sphinx-design", "sphinxcontrib.mermaid (>=0.7.1,<0.8.0)", "sphinxext-opengraph (>=0.6.3,<0.7.0)", "sphinxext-rediraffe (>=0.2.7,<0.3.0)"] -testing = ["beautifulsoup4", "coverage[toml]", "pytest (>=6,<7)", "pytest-cov", "pytest-param-files (>=0.3.4,<0.4.0)", "pytest-regressions", "sphinx (<5.2)", "sphinx-pytest"] - -[[package]] -name = "nodeenv" -version = "1.8.0" -description = "Node.js virtual environment builder" -optional = false -python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" -files = [ - {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, - {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, -] - -[package.dependencies] -setuptools = "*" - -[[package]] -name = "orjson" -version = "3.9.5" -description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy" -optional = true -python-versions = ">=3.7" -files = [ - {file = "orjson-3.9.5-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:ad6845912a71adcc65df7c8a7f2155eba2096cf03ad2c061c93857de70d699ad"}, - {file = "orjson-3.9.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e298e0aacfcc14ef4476c3f409e85475031de24e5b23605a465e9bf4b2156273"}, - {file = "orjson-3.9.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:83c9939073281ef7dd7c5ca7f54cceccb840b440cec4b8a326bda507ff88a0a6"}, - {file = "orjson-3.9.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e174cc579904a48ee1ea3acb7045e8a6c5d52c17688dfcb00e0e842ec378cabf"}, - {file = "orjson-3.9.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f8d51702f42c785b115401e1d64a27a2ea767ae7cf1fb8edaa09c7cf1571c660"}, - {file = "orjson-3.9.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f13d61c0c7414ddee1ef4d0f303e2222f8cced5a2e26d9774751aecd72324c9e"}, - {file = "orjson-3.9.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d748cc48caf5a91c883d306ab648df1b29e16b488c9316852844dd0fd000d1c2"}, - {file = "orjson-3.9.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bd19bc08fa023e4c2cbf8294ad3f2b8922f4de9ba088dbc71e6b268fdf54591c"}, - {file = "orjson-3.9.5-cp310-none-win32.whl", hash = "sha256:5793a21a21bf34e1767e3d61a778a25feea8476dcc0bdf0ae1bc506dc34561ea"}, - {file = "orjson-3.9.5-cp310-none-win_amd64.whl", hash = "sha256:2bcec0b1024d0031ab3eab7a8cb260c8a4e4a5e35993878a2da639d69cdf6a65"}, - {file = "orjson-3.9.5-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:8547b95ca0e2abd17e1471973e6d676f1d8acedd5f8fb4f739e0612651602d66"}, - {file = "orjson-3.9.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87ce174d6a38d12b3327f76145acbd26f7bc808b2b458f61e94d83cd0ebb4d76"}, - {file = "orjson-3.9.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a960bb1bc9a964d16fcc2d4af5a04ce5e4dfddca84e3060c35720d0a062064fe"}, - {file = "orjson-3.9.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a7aa5573a949760d6161d826d34dc36db6011926f836851fe9ccb55b5a7d8e8"}, - {file = "orjson-3.9.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b2852afca17d7eea85f8e200d324e38c851c96598ac7b227e4f6c4e59fbd3df"}, - {file = "orjson-3.9.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa185959c082475288da90f996a82e05e0c437216b96f2a8111caeb1d54ef926"}, - {file = "orjson-3.9.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:89c9332695b838438ea4b9a482bce8ffbfddde4df92750522d928fb00b7b8dce"}, - {file = "orjson-3.9.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:2493f1351a8f0611bc26e2d3d407efb873032b4f6b8926fed8cfed39210ca4ba"}, - {file = "orjson-3.9.5-cp311-none-win32.whl", hash = "sha256:ffc544e0e24e9ae69301b9a79df87a971fa5d1c20a6b18dca885699709d01be0"}, - {file = "orjson-3.9.5-cp311-none-win_amd64.whl", hash = "sha256:89670fe2732e3c0c54406f77cad1765c4c582f67b915c74fda742286809a0cdc"}, - {file = "orjson-3.9.5-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:15df211469625fa27eced4aa08dc03e35f99c57d45a33855cc35f218ea4071b8"}, - {file = "orjson-3.9.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d9f17c59fe6c02bc5f89ad29edb0253d3059fe8ba64806d789af89a45c35269a"}, - {file = "orjson-3.9.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca6b96659c7690773d8cebb6115c631f4a259a611788463e9c41e74fa53bf33f"}, - {file = "orjson-3.9.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a26fafe966e9195b149950334bdbe9026eca17fe8ffe2d8fa87fdc30ca925d30"}, - {file = "orjson-3.9.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9006b1eb645ecf460da067e2dd17768ccbb8f39b01815a571bfcfab7e8da5e52"}, - {file = "orjson-3.9.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ebfdbf695734b1785e792a1315e41835ddf2a3e907ca0e1c87a53f23006ce01d"}, - {file = "orjson-3.9.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4a3943234342ab37d9ed78fb0a8f81cd4b9532f67bf2ac0d3aa45fa3f0a339f3"}, - {file = "orjson-3.9.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e6762755470b5c82f07b96b934af32e4d77395a11768b964aaa5eb092817bc31"}, - {file = "orjson-3.9.5-cp312-none-win_amd64.whl", hash = "sha256:c74df28749c076fd6e2157190df23d43d42b2c83e09d79b51694ee7315374ad5"}, - {file = "orjson-3.9.5-cp37-cp37m-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:88e18a74d916b74f00d0978d84e365c6bf0e7ab846792efa15756b5fb2f7d49d"}, - {file = "orjson-3.9.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d28514b5b6dfaf69097be70d0cf4f1407ec29d0f93e0b4131bf9cc8fd3f3e374"}, - {file = "orjson-3.9.5-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b81aca8c7be61e2566246b6a0ca49f8aece70dd3f38c7f5c837f398c4cb142"}, - {file = "orjson-3.9.5-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:385c1c713b1e47fd92e96cf55fd88650ac6dfa0b997e8aa7ecffd8b5865078b1"}, - {file = "orjson-3.9.5-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f9850c03a8e42fba1a508466e6a0f99472fd2b4a5f30235ea49b2a1b32c04c11"}, - {file = "orjson-3.9.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4449f84bbb13bcef493d8aa669feadfced0f7c5eea2d0d88b5cc21f812183af8"}, - {file = "orjson-3.9.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:86127bf194f3b873135e44ce5dc9212cb152b7e06798d5667a898a00f0519be4"}, - {file = "orjson-3.9.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0abcd039f05ae9ab5b0ff11624d0b9e54376253b7d3217a358d09c3edf1d36f7"}, - {file = "orjson-3.9.5-cp37-none-win32.whl", hash = "sha256:10cc8ad5ff7188efcb4bec196009d61ce525a4e09488e6d5db41218c7fe4f001"}, - {file = "orjson-3.9.5-cp37-none-win_amd64.whl", hash = "sha256:ff27e98532cb87379d1a585837d59b187907228268e7b0a87abe122b2be6968e"}, - {file = "orjson-3.9.5-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:5bfa79916ef5fef75ad1f377e54a167f0de334c1fa4ebb8d0224075f3ec3d8c0"}, - {file = "orjson-3.9.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e87dfa6ac0dae764371ab19b35eaaa46dfcb6ef2545dfca03064f21f5d08239f"}, - {file = "orjson-3.9.5-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:50ced24a7b23058b469ecdb96e36607fc611cbaee38b58e62a55c80d1b3ad4e1"}, - {file = "orjson-3.9.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b1b74ea2a3064e1375da87788897935832e806cc784de3e789fd3c4ab8eb3fa5"}, - {file = "orjson-3.9.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a7cb961efe013606913d05609f014ad43edfaced82a576e8b520a5574ce3b2b9"}, - {file = "orjson-3.9.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1225d2d5ee76a786bda02f8c5e15017462f8432bb960de13d7c2619dba6f0275"}, - {file = "orjson-3.9.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f39f4b99199df05c7ecdd006086259ed25886cdbd7b14c8cdb10c7675cfcca7d"}, - {file = "orjson-3.9.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:a461dc9fb60cac44f2d3218c36a0c1c01132314839a0e229d7fb1bba69b810d8"}, - {file = "orjson-3.9.5-cp38-none-win32.whl", hash = "sha256:dedf1a6173748202df223aea29de814b5836732a176b33501375c66f6ab7d822"}, - {file = "orjson-3.9.5-cp38-none-win_amd64.whl", hash = "sha256:fa504082f53efcbacb9087cc8676c163237beb6e999d43e72acb4bb6f0db11e6"}, - {file = "orjson-3.9.5-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6900f0248edc1bec2a2a3095a78a7e3ef4e63f60f8ddc583687eed162eedfd69"}, - {file = "orjson-3.9.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:17404333c40047888ac40bd8c4d49752a787e0a946e728a4e5723f111b6e55a5"}, - {file = "orjson-3.9.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0eefb7cfdd9c2bc65f19f974a5d1dfecbac711dae91ed635820c6b12da7a3c11"}, - {file = "orjson-3.9.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:68c78b2a3718892dc018adbc62e8bab6ef3c0d811816d21e6973dee0ca30c152"}, - {file = "orjson-3.9.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:591ad7d9e4a9f9b104486ad5d88658c79ba29b66c5557ef9edf8ca877a3f8d11"}, - {file = "orjson-3.9.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6cc2cbf302fbb2d0b2c3c142a663d028873232a434d89ce1b2604ebe5cc93ce8"}, - {file = "orjson-3.9.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b26b5aa5e9ee1bad2795b925b3adb1b1b34122cb977f30d89e0a1b3f24d18450"}, - {file = "orjson-3.9.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ef84724f7d29dcfe3aafb1fc5fc7788dca63e8ae626bb9298022866146091a3e"}, - {file = "orjson-3.9.5-cp39-none-win32.whl", hash = "sha256:664cff27f85939059472afd39acff152fbac9a091b7137092cb651cf5f7747b5"}, - {file = "orjson-3.9.5-cp39-none-win_amd64.whl", hash = "sha256:91dda66755795ac6100e303e206b636568d42ac83c156547634256a2e68de694"}, - {file = "orjson-3.9.5.tar.gz", hash = "sha256:6daf5ee0b3cf530b9978cdbf71024f1c16ed4a67d05f6ec435c6e7fe7a52724c"}, -] - -[[package]] -name = "packaging" -version = "23.1" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.7" -files = [ - {file = "packaging-23.1-py3-none-any.whl", hash = "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61"}, - {file = "packaging-23.1.tar.gz", hash = "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f"}, -] - -[[package]] -name = "platformdirs" -version = "3.10.0" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -optional = false -python-versions = ">=3.7" -files = [ - {file = "platformdirs-3.10.0-py3-none-any.whl", hash = "sha256:d7c24979f292f916dc9cbf8648319032f551ea8c49a4c9bf2fb556a02070ec1d"}, - {file = "platformdirs-3.10.0.tar.gz", hash = "sha256:b45696dab2d7cc691a3226759c0d3b00c47c8b6e293d96f6436f733303f77f6d"}, -] - -[package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] - -[[package]] -name = "pluggy" -version = "1.3.0" -description = "plugin and hook calling mechanisms for python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, - {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, -] - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] - -[[package]] -name = "pre-commit" -version = "3.3.3" -description = "A framework for managing and maintaining multi-language pre-commit hooks." -optional = false -python-versions = ">=3.8" -files = [ - {file = "pre_commit-3.3.3-py2.py3-none-any.whl", hash = "sha256:10badb65d6a38caff29703362271d7dca483d01da88f9d7e05d0b97171c136cb"}, - {file = "pre_commit-3.3.3.tar.gz", hash = "sha256:a2256f489cd913d575c145132ae196fe335da32d91a8294b7afe6622335dd023"}, -] - -[package.dependencies] -cfgv = ">=2.0.0" -identify = ">=1.0.0" -nodeenv = ">=0.11.1" -pyyaml = ">=5.1" -virtualenv = ">=20.10.0" - -[[package]] -name = "pycparser" -version = "2.21" -description = "C parser in Python" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -files = [ - {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, - {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, -] - -[[package]] -name = "pydantic" -version = "2.3.0" -description = "Data validation using Python type hints" -optional = false -python-versions = ">=3.7" -files = [ - {file = "pydantic-2.3.0-py3-none-any.whl", hash = "sha256:45b5e446c6dfaad9444819a293b921a40e1db1aa61ea08aede0522529ce90e81"}, - {file = "pydantic-2.3.0.tar.gz", hash = "sha256:1607cc106602284cd4a00882986570472f193fde9cb1259bceeaedb26aa79a6d"}, -] - -[package.dependencies] -annotated-types = ">=0.4.0" -pydantic-core = "2.6.3" -typing-extensions = ">=4.6.1" - -[package.extras] -email = ["email-validator (>=2.0.0)"] - -[[package]] -name = "pydantic-core" -version = "2.6.3" -description = "" -optional = false -python-versions = ">=3.7" -files = [ - {file = "pydantic_core-2.6.3-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:1a0ddaa723c48af27d19f27f1c73bdc615c73686d763388c8683fe34ae777bad"}, - {file = "pydantic_core-2.6.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5cfde4fab34dd1e3a3f7f3db38182ab6c95e4ea91cf322242ee0be5c2f7e3d2f"}, - {file = "pydantic_core-2.6.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5493a7027bfc6b108e17c3383959485087d5942e87eb62bbac69829eae9bc1f7"}, - {file = "pydantic_core-2.6.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:84e87c16f582f5c753b7f39a71bd6647255512191be2d2dbf49458c4ef024588"}, - {file = "pydantic_core-2.6.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:522a9c4a4d1924facce7270c84b5134c5cabcb01513213662a2e89cf28c1d309"}, - {file = "pydantic_core-2.6.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aaafc776e5edc72b3cad1ccedb5fd869cc5c9a591f1213aa9eba31a781be9ac1"}, - {file = "pydantic_core-2.6.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a750a83b2728299ca12e003d73d1264ad0440f60f4fc9cee54acc489249b728"}, - {file = "pydantic_core-2.6.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9e8b374ef41ad5c461efb7a140ce4730661aadf85958b5c6a3e9cf4e040ff4bb"}, - {file = "pydantic_core-2.6.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b594b64e8568cf09ee5c9501ede37066b9fc41d83d58f55b9952e32141256acd"}, - {file = "pydantic_core-2.6.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2a20c533cb80466c1d42a43a4521669ccad7cf2967830ac62c2c2f9cece63e7e"}, - {file = "pydantic_core-2.6.3-cp310-none-win32.whl", hash = "sha256:04fe5c0a43dec39aedba0ec9579001061d4653a9b53a1366b113aca4a3c05ca7"}, - {file = "pydantic_core-2.6.3-cp310-none-win_amd64.whl", hash = "sha256:6bf7d610ac8f0065a286002a23bcce241ea8248c71988bda538edcc90e0c39ad"}, - {file = "pydantic_core-2.6.3-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:6bcc1ad776fffe25ea5c187a028991c031a00ff92d012ca1cc4714087e575973"}, - {file = "pydantic_core-2.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:df14f6332834444b4a37685810216cc8fe1fe91f447332cd56294c984ecbff1c"}, - {file = "pydantic_core-2.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0b7486d85293f7f0bbc39b34e1d8aa26210b450bbd3d245ec3d732864009819"}, - {file = "pydantic_core-2.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a892b5b1871b301ce20d40b037ffbe33d1407a39639c2b05356acfef5536d26a"}, - {file = "pydantic_core-2.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:883daa467865e5766931e07eb20f3e8152324f0adf52658f4d302242c12e2c32"}, - {file = "pydantic_core-2.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4eb77df2964b64ba190eee00b2312a1fd7a862af8918ec70fc2d6308f76ac64"}, - {file = "pydantic_core-2.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ce8c84051fa292a5dc54018a40e2a1926fd17980a9422c973e3ebea017aa8da"}, - {file = "pydantic_core-2.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:22134a4453bd59b7d1e895c455fe277af9d9d9fbbcb9dc3f4a97b8693e7e2c9b"}, - {file = "pydantic_core-2.6.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:02e1c385095efbd997311d85c6021d32369675c09bcbfff3b69d84e59dc103f6"}, - {file = "pydantic_core-2.6.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d79f1f2f7ebdb9b741296b69049ff44aedd95976bfee38eb4848820628a99b50"}, - {file = "pydantic_core-2.6.3-cp311-none-win32.whl", hash = "sha256:430ddd965ffd068dd70ef4e4d74f2c489c3a313adc28e829dd7262cc0d2dd1e8"}, - {file = "pydantic_core-2.6.3-cp311-none-win_amd64.whl", hash = "sha256:84f8bb34fe76c68c9d96b77c60cef093f5e660ef8e43a6cbfcd991017d375950"}, - {file = "pydantic_core-2.6.3-cp311-none-win_arm64.whl", hash = "sha256:5a2a3c9ef904dcdadb550eedf3291ec3f229431b0084666e2c2aa8ff99a103a2"}, - {file = "pydantic_core-2.6.3-cp312-cp312-macosx_10_7_x86_64.whl", hash = "sha256:8421cf496e746cf8d6b677502ed9a0d1e4e956586cd8b221e1312e0841c002d5"}, - {file = "pydantic_core-2.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bb128c30cf1df0ab78166ded1ecf876620fb9aac84d2413e8ea1594b588c735d"}, - {file = "pydantic_core-2.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37a822f630712817b6ecc09ccc378192ef5ff12e2c9bae97eb5968a6cdf3b862"}, - {file = "pydantic_core-2.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:240a015102a0c0cc8114f1cba6444499a8a4d0333e178bc504a5c2196defd456"}, - {file = "pydantic_core-2.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f90e5e3afb11268628c89f378f7a1ea3f2fe502a28af4192e30a6cdea1e7d5e"}, - {file = "pydantic_core-2.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:340e96c08de1069f3d022a85c2a8c63529fd88709468373b418f4cf2c949fb0e"}, - {file = "pydantic_core-2.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1480fa4682e8202b560dcdc9eeec1005f62a15742b813c88cdc01d44e85308e5"}, - {file = "pydantic_core-2.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f14546403c2a1d11a130b537dda28f07eb6c1805a43dae4617448074fd49c282"}, - {file = "pydantic_core-2.6.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:a87c54e72aa2ef30189dc74427421e074ab4561cf2bf314589f6af5b37f45e6d"}, - {file = "pydantic_core-2.6.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f93255b3e4d64785554e544c1c76cd32f4a354fa79e2eeca5d16ac2e7fdd57aa"}, - {file = "pydantic_core-2.6.3-cp312-none-win32.whl", hash = "sha256:f70dc00a91311a1aea124e5f64569ea44c011b58433981313202c46bccbec0e1"}, - {file = "pydantic_core-2.6.3-cp312-none-win_amd64.whl", hash = "sha256:23470a23614c701b37252618e7851e595060a96a23016f9a084f3f92f5ed5881"}, - {file = "pydantic_core-2.6.3-cp312-none-win_arm64.whl", hash = "sha256:1ac1750df1b4339b543531ce793b8fd5c16660a95d13aecaab26b44ce11775e9"}, - {file = "pydantic_core-2.6.3-cp37-cp37m-macosx_10_7_x86_64.whl", hash = "sha256:a53e3195f134bde03620d87a7e2b2f2046e0e5a8195e66d0f244d6d5b2f6d31b"}, - {file = "pydantic_core-2.6.3-cp37-cp37m-macosx_11_0_arm64.whl", hash = "sha256:f2969e8f72c6236c51f91fbb79c33821d12a811e2a94b7aa59c65f8dbdfad34a"}, - {file = "pydantic_core-2.6.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:672174480a85386dd2e681cadd7d951471ad0bb028ed744c895f11f9d51b9ebe"}, - {file = "pydantic_core-2.6.3-cp37-cp37m-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:002d0ea50e17ed982c2d65b480bd975fc41086a5a2f9c924ef8fc54419d1dea3"}, - {file = "pydantic_core-2.6.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ccc13afee44b9006a73d2046068d4df96dc5b333bf3509d9a06d1b42db6d8bf"}, - {file = "pydantic_core-2.6.3-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:439a0de139556745ae53f9cc9668c6c2053444af940d3ef3ecad95b079bc9987"}, - {file = "pydantic_core-2.6.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d63b7545d489422d417a0cae6f9898618669608750fc5e62156957e609e728a5"}, - {file = "pydantic_core-2.6.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b44c42edc07a50a081672e25dfe6022554b47f91e793066a7b601ca290f71e42"}, - {file = "pydantic_core-2.6.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1c721bfc575d57305dd922e6a40a8fe3f762905851d694245807a351ad255c58"}, - {file = "pydantic_core-2.6.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:5e4a2cf8c4543f37f5dc881de6c190de08096c53986381daebb56a355be5dfe6"}, - {file = "pydantic_core-2.6.3-cp37-none-win32.whl", hash = "sha256:d9b4916b21931b08096efed090327f8fe78e09ae8f5ad44e07f5c72a7eedb51b"}, - {file = "pydantic_core-2.6.3-cp37-none-win_amd64.whl", hash = "sha256:a8acc9dedd304da161eb071cc7ff1326aa5b66aadec9622b2574ad3ffe225525"}, - {file = "pydantic_core-2.6.3-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:5e9c068f36b9f396399d43bfb6defd4cc99c36215f6ff33ac8b9c14ba15bdf6b"}, - {file = "pydantic_core-2.6.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e61eae9b31799c32c5f9b7be906be3380e699e74b2db26c227c50a5fc7988698"}, - {file = "pydantic_core-2.6.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85463560c67fc65cd86153a4975d0b720b6d7725cf7ee0b2d291288433fc21b"}, - {file = "pydantic_core-2.6.3-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9616567800bdc83ce136e5847d41008a1d602213d024207b0ff6cab6753fe645"}, - {file = "pydantic_core-2.6.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9e9b65a55bbabda7fccd3500192a79f6e474d8d36e78d1685496aad5f9dbd92c"}, - {file = "pydantic_core-2.6.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f468d520f47807d1eb5d27648393519655eadc578d5dd862d06873cce04c4d1b"}, - {file = "pydantic_core-2.6.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9680dd23055dd874173a3a63a44e7f5a13885a4cfd7e84814be71be24fba83db"}, - {file = "pydantic_core-2.6.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a718d56c4d55efcfc63f680f207c9f19c8376e5a8a67773535e6f7e80e93170"}, - {file = "pydantic_core-2.6.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8ecbac050856eb6c3046dea655b39216597e373aa8e50e134c0e202f9c47efec"}, - {file = "pydantic_core-2.6.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:788be9844a6e5c4612b74512a76b2153f1877cd845410d756841f6c3420230eb"}, - {file = "pydantic_core-2.6.3-cp38-none-win32.whl", hash = "sha256:07a1aec07333bf5adebd8264047d3dc518563d92aca6f2f5b36f505132399efc"}, - {file = "pydantic_core-2.6.3-cp38-none-win_amd64.whl", hash = "sha256:621afe25cc2b3c4ba05fff53525156d5100eb35c6e5a7cf31d66cc9e1963e378"}, - {file = "pydantic_core-2.6.3-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:813aab5bfb19c98ae370952b6f7190f1e28e565909bfc219a0909db168783465"}, - {file = "pydantic_core-2.6.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:50555ba3cb58f9861b7a48c493636b996a617db1a72c18da4d7f16d7b1b9952b"}, - {file = "pydantic_core-2.6.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19e20f8baedd7d987bd3f8005c146e6bcbda7cdeefc36fad50c66adb2dd2da48"}, - {file = "pydantic_core-2.6.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b0a5d7edb76c1c57b95df719af703e796fc8e796447a1da939f97bfa8a918d60"}, - {file = "pydantic_core-2.6.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f06e21ad0b504658a3a9edd3d8530e8cea5723f6ea5d280e8db8efc625b47e49"}, - {file = "pydantic_core-2.6.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea053cefa008fda40f92aab937fb9f183cf8752e41dbc7bc68917884454c6362"}, - {file = "pydantic_core-2.6.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:171a4718860790f66d6c2eda1d95dd1edf64f864d2e9f9115840840cf5b5713f"}, - {file = "pydantic_core-2.6.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ed7ceca6aba5331ece96c0e328cd52f0dcf942b8895a1ed2642de50800b79d3"}, - {file = "pydantic_core-2.6.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:acafc4368b289a9f291e204d2c4c75908557d4f36bd3ae937914d4529bf62a76"}, - {file = "pydantic_core-2.6.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1aa712ba150d5105814e53cb141412217146fedc22621e9acff9236d77d2a5ef"}, - {file = "pydantic_core-2.6.3-cp39-none-win32.whl", hash = "sha256:44b4f937b992394a2e81a5c5ce716f3dcc1237281e81b80c748b2da6dd5cf29a"}, - {file = "pydantic_core-2.6.3-cp39-none-win_amd64.whl", hash = "sha256:9b33bf9658cb29ac1a517c11e865112316d09687d767d7a0e4a63d5c640d1b17"}, - {file = "pydantic_core-2.6.3-pp310-pypy310_pp73-macosx_10_7_x86_64.whl", hash = "sha256:d7050899026e708fb185e174c63ebc2c4ee7a0c17b0a96ebc50e1f76a231c057"}, - {file = "pydantic_core-2.6.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:99faba727727b2e59129c59542284efebbddade4f0ae6a29c8b8d3e1f437beb7"}, - {file = "pydantic_core-2.6.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5fa159b902d22b283b680ef52b532b29554ea2a7fc39bf354064751369e9dbd7"}, - {file = "pydantic_core-2.6.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:046af9cfb5384f3684eeb3f58a48698ddab8dd870b4b3f67f825353a14441418"}, - {file = "pydantic_core-2.6.3-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:930bfe73e665ebce3f0da2c6d64455098aaa67e1a00323c74dc752627879fc67"}, - {file = "pydantic_core-2.6.3-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:85cc4d105747d2aa3c5cf3e37dac50141bff779545ba59a095f4a96b0a460e70"}, - {file = "pydantic_core-2.6.3-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b25afe9d5c4f60dcbbe2b277a79be114e2e65a16598db8abee2a2dcde24f162b"}, - {file = "pydantic_core-2.6.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e49ce7dc9f925e1fb010fc3d555250139df61fa6e5a0a95ce356329602c11ea9"}, - {file = "pydantic_core-2.6.3-pp37-pypy37_pp73-macosx_10_7_x86_64.whl", hash = "sha256:2dd50d6a1aef0426a1d0199190c6c43ec89812b1f409e7fe44cb0fbf6dfa733c"}, - {file = "pydantic_core-2.6.3-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c6595b0d8c8711e8e1dc389d52648b923b809f68ac1c6f0baa525c6440aa0daa"}, - {file = "pydantic_core-2.6.3-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ef724a059396751aef71e847178d66ad7fc3fc969a1a40c29f5aac1aa5f8784"}, - {file = "pydantic_core-2.6.3-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3c8945a105f1589ce8a693753b908815e0748f6279959a4530f6742e1994dcb6"}, - {file = "pydantic_core-2.6.3-pp37-pypy37_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c8c6660089a25d45333cb9db56bb9e347241a6d7509838dbbd1931d0e19dbc7f"}, - {file = "pydantic_core-2.6.3-pp37-pypy37_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:692b4ff5c4e828a38716cfa92667661a39886e71136c97b7dac26edef18767f7"}, - {file = "pydantic_core-2.6.3-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:f1a5d8f18877474c80b7711d870db0eeef9442691fcdb00adabfc97e183ee0b0"}, - {file = "pydantic_core-2.6.3-pp38-pypy38_pp73-macosx_10_7_x86_64.whl", hash = "sha256:3796a6152c545339d3b1652183e786df648ecdf7c4f9347e1d30e6750907f5bb"}, - {file = "pydantic_core-2.6.3-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:b962700962f6e7a6bd77e5f37320cabac24b4c0f76afeac05e9f93cf0c620014"}, - {file = "pydantic_core-2.6.3-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:56ea80269077003eaa59723bac1d8bacd2cd15ae30456f2890811efc1e3d4413"}, - {file = "pydantic_core-2.6.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75c0ebbebae71ed1e385f7dfd9b74c1cff09fed24a6df43d326dd7f12339ec34"}, - {file = "pydantic_core-2.6.3-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:252851b38bad3bfda47b104ffd077d4f9604a10cb06fe09d020016a25107bf98"}, - {file = "pydantic_core-2.6.3-pp38-pypy38_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:6656a0ae383d8cd7cc94e91de4e526407b3726049ce8d7939049cbfa426518c8"}, - {file = "pydantic_core-2.6.3-pp38-pypy38_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:d9140ded382a5b04a1c030b593ed9bf3088243a0a8b7fa9f071a5736498c5483"}, - {file = "pydantic_core-2.6.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:d38bbcef58220f9c81e42c255ef0bf99735d8f11edef69ab0b499da77105158a"}, - {file = "pydantic_core-2.6.3-pp39-pypy39_pp73-macosx_10_7_x86_64.whl", hash = "sha256:c9d469204abcca28926cbc28ce98f28e50e488767b084fb3fbdf21af11d3de26"}, - {file = "pydantic_core-2.6.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:48c1ed8b02ffea4d5c9c220eda27af02b8149fe58526359b3c07eb391cb353a2"}, - {file = "pydantic_core-2.6.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b2b1bfed698fa410ab81982f681f5b1996d3d994ae8073286515ac4d165c2e7"}, - {file = "pydantic_core-2.6.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf9d42a71a4d7a7c1f14f629e5c30eac451a6fc81827d2beefd57d014c006c4a"}, - {file = "pydantic_core-2.6.3-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4292ca56751aebbe63a84bbfc3b5717abb09b14d4b4442cc43fd7c49a1529efd"}, - {file = "pydantic_core-2.6.3-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:7dc2ce039c7290b4ef64334ec7e6ca6494de6eecc81e21cb4f73b9b39991408c"}, - {file = "pydantic_core-2.6.3-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:615a31b1629e12445c0e9fc8339b41aaa6cc60bd53bf802d5fe3d2c0cda2ae8d"}, - {file = "pydantic_core-2.6.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:1fa1f6312fb84e8c281f32b39affe81984ccd484da6e9d65b3d18c202c666149"}, - {file = "pydantic_core-2.6.3.tar.gz", hash = "sha256:1508f37ba9e3ddc0189e6ff4e2228bd2d3c3a4641cbe8c07177162f76ed696c7"}, -] - -[package.dependencies] -typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" - -[[package]] -name = "pygments" -version = "2.16.1" -description = "Pygments is a syntax highlighting package written in Python." -optional = true -python-versions = ">=3.7" -files = [ - {file = "Pygments-2.16.1-py3-none-any.whl", hash = "sha256:13fc09fa63bc8d8671a6d247e1eb303c4b343eaee81d861f3404db2935653692"}, - {file = "Pygments-2.16.1.tar.gz", hash = "sha256:1daff0494820c69bc8941e407aa20f577374ee88364ee10a98fdbe0aece96e29"}, -] - -[package.extras] -plugins = ["importlib-metadata"] - -[[package]] -name = "pyproject-api" -version = "1.5.4" -description = "API to interact with the python pyproject.toml based projects" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pyproject_api-1.5.4-py3-none-any.whl", hash = "sha256:ca462d457880340ceada078678a296ac500061cef77a040e1143004470ab0046"}, - {file = "pyproject_api-1.5.4.tar.gz", hash = "sha256:8d41f3f0c04f0f6a830c27b1c425fa66699715ae06d8a054a1c5eeaaf8bfb145"}, -] - -[package.dependencies] -packaging = ">=23.1" -tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} - -[package.extras] -docs = ["furo (>=2023.7.26)", "sphinx (<7.2)", "sphinx-autodoc-typehints (>=1.24)"] -testing = ["covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "setuptools (>=68)", "wheel (>=0.41.1)"] - -[[package]] -name = "pytest" -version = "7.4.0" -description = "pytest: simple powerful testing with Python" -optional = false -python-versions = ">=3.7" -files = [ - {file = "pytest-7.4.0-py3-none-any.whl", hash = "sha256:78bf16451a2eb8c7a2ea98e32dc119fd2aa758f1d5d66dbf0a59d69a3969df32"}, - {file = "pytest-7.4.0.tar.gz", hash = "sha256:b4bf8c45bd59934ed84001ad51e11b4ee40d40a1229d2c79f9c592b0a3f6bd8a"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -iniconfig = "*" -packaging = "*" -pluggy = ">=0.12,<2.0" -tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} - -[package.extras] -testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] - -[[package]] -name = "pytest-asyncio" -version = "0.21.1" -description = "Pytest support for asyncio" -optional = false -python-versions = ">=3.7" -files = [ - {file = "pytest-asyncio-0.21.1.tar.gz", hash = "sha256:40a7eae6dded22c7b604986855ea48400ab15b069ae38116e8c01238e9eeb64d"}, - {file = "pytest_asyncio-0.21.1-py3-none-any.whl", hash = "sha256:8666c1c8ac02631d7c51ba282e0c69a8a452b211ffedf2599099845da5c5c37b"}, -] - -[package.dependencies] -pytest = ">=7.0.0" - -[package.extras] -docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] -testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] - -[[package]] -name = "pytest-cov" -version = "4.1.0" -description = "Pytest plugin for measuring coverage." -optional = false -python-versions = ">=3.7" -files = [ - {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, - {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, -] - -[package.dependencies] -coverage = {version = ">=5.2.1", extras = ["toml"]} -pytest = ">=4.6" - -[package.extras] -testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] - -[[package]] -name = "pytest-mock" -version = "3.11.1" -description = "Thin-wrapper around the mock package for easier use with pytest" -optional = false -python-versions = ">=3.7" -files = [ - {file = "pytest-mock-3.11.1.tar.gz", hash = "sha256:7f6b125602ac6d743e523ae0bfa71e1a697a2f5534064528c6ff84c2f7c2fc7f"}, - {file = "pytest_mock-3.11.1-py3-none-any.whl", hash = "sha256:21c279fff83d70763b05f8874cc9cfb3fcacd6d354247a976f9529d19f9acf39"}, -] - -[package.dependencies] -pytest = ">=5.0" - -[package.extras] -dev = ["pre-commit", "pytest-asyncio", "tox"] - -[[package]] -name = "pytest-sugar" -version = "0.9.7" -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)." -optional = false -python-versions = "*" -files = [ - {file = "pytest-sugar-0.9.7.tar.gz", hash = "sha256:f1e74c1abfa55f7241cf7088032b6e378566f16b938f3f08905e2cf4494edd46"}, - {file = "pytest_sugar-0.9.7-py2.py3-none-any.whl", hash = "sha256:8cb5a4e5f8bbcd834622b0235db9e50432f4cbd71fef55b467fe44e43701e062"}, -] - -[package.dependencies] -packaging = ">=21.3" -pytest = ">=6.2.0" -termcolor = ">=2.1.0" - -[package.extras] -dev = ["black", "flake8", "pre-commit"] - -[[package]] -name = "pytz" -version = "2023.3" -description = "World timezone definitions, modern and historical" -optional = true -python-versions = "*" -files = [ - {file = "pytz-2023.3-py2.py3-none-any.whl", hash = "sha256:a151b3abb88eda1d4e34a9814df37de2a80e301e68ba0fd856fb9b46bfbbbffb"}, - {file = "pytz-2023.3.tar.gz", hash = "sha256:1d8ce29db189191fb55338ee6d0387d82ab59f3d00eac103412d64e0ebd0c588"}, -] - -[[package]] -name = "pyyaml" -version = "6.0.1" -description = "YAML parser and emitter for Python" -optional = false -python-versions = ">=3.6" -files = [ - {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, - {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, - {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, - {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, - {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, - {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, - {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, - {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, - {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, - {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, - {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, - {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, - {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, - {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, - {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, - {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, - {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, - {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, - {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, - {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, - {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, - {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, - {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, - {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, - {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, - {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, - {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, - {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, - {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, - {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, - {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, - {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, - {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, - {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, - {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, -] - -[[package]] -name = "requests" -version = "2.31.0" -description = "Python HTTP for Humans." -optional = false -python-versions = ">=3.7" -files = [ - {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, - {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, -] - -[package.dependencies] -certifi = ">=2017.4.17" -charset-normalizer = ">=2,<4" -idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] - -[[package]] -name = "setuptools" -version = "68.1.2" -description = "Easily download, build, install, upgrade, and uninstall Python packages" -optional = false -python-versions = ">=3.8" -files = [ - {file = "setuptools-68.1.2-py3-none-any.whl", hash = "sha256:3d8083eed2d13afc9426f227b24fd1659489ec107c0e86cec2ffdde5c92e790b"}, - {file = "setuptools-68.1.2.tar.gz", hash = "sha256:3d4dfa6d95f1b101d695a6160a7626e15583af71a5f52176efa5d39a054d475d"}, -] - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5,<=7.1.2)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] -testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] - -[[package]] -name = "six" -version = "1.16.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, -] - -[[package]] -name = "sniffio" -version = "1.3.0" -description = "Sniff out which async library your code is running under" -optional = false -python-versions = ">=3.7" -files = [ - {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, - {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, -] - -[[package]] -name = "snowballstemmer" -version = "2.2.0" -description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." -optional = true -python-versions = "*" -files = [ - {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, - {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, -] - -[[package]] -name = "sphinx" -version = "4.5.0" -description = "Python documentation generator" -optional = true -python-versions = ">=3.6" -files = [ - {file = "Sphinx-4.5.0-py3-none-any.whl", hash = "sha256:ebf612653238bcc8f4359627a9b7ce44ede6fdd75d9d30f68255c7383d3a6226"}, - {file = "Sphinx-4.5.0.tar.gz", hash = "sha256:7bf8ca9637a4ee15af412d1a1d9689fec70523a68ca9bb9127c2f3eeb344e2e6"}, -] - -[package.dependencies] -alabaster = ">=0.7,<0.8" -babel = ">=1.3" -colorama = {version = ">=0.3.5", markers = "sys_platform == \"win32\""} -docutils = ">=0.14,<0.18" -imagesize = "*" -importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} -Jinja2 = ">=2.3" -packaging = "*" -Pygments = ">=2.0" -requests = ">=2.5.0" -snowballstemmer = ">=1.1" -sphinxcontrib-applehelp = "*" -sphinxcontrib-devhelp = "*" -sphinxcontrib-htmlhelp = ">=2.0.0" -sphinxcontrib-jsmath = "*" -sphinxcontrib-qthelp = "*" -sphinxcontrib-serializinghtml = ">=1.1.5" - -[package.extras] -docs = ["sphinxcontrib-websupport"] -lint = ["docutils-stubs", "flake8 (>=3.5.0)", "isort", "mypy (>=0.931)", "types-requests", "types-typed-ast"] -test = ["cython", "html5lib", "pytest", "pytest-cov", "typed-ast"] - -[[package]] -name = "sphinx-rtd-theme" -version = "0.5.1" -description = "Read the Docs theme for Sphinx" -optional = true -python-versions = "*" -files = [ - {file = "sphinx_rtd_theme-0.5.1-py2.py3-none-any.whl", hash = "sha256:fa6bebd5ab9a73da8e102509a86f3fcc36dec04a0b52ea80e5a033b2aba00113"}, - {file = "sphinx_rtd_theme-0.5.1.tar.gz", hash = "sha256:eda689eda0c7301a80cf122dad28b1861e5605cbf455558f3775e1e8200e83a5"}, -] - -[package.dependencies] -sphinx = "*" - -[package.extras] -dev = ["bump2version", "sphinxcontrib-httpdomain", "transifex-client"] - -[[package]] -name = "sphinxcontrib-applehelp" -version = "1.0.4" -description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" -optional = true -python-versions = ">=3.8" -files = [ - {file = "sphinxcontrib-applehelp-1.0.4.tar.gz", hash = "sha256:828f867945bbe39817c210a1abfd1bc4895c8b73fcaade56d45357a348a07d7e"}, - {file = "sphinxcontrib_applehelp-1.0.4-py3-none-any.whl", hash = "sha256:29d341f67fb0f6f586b23ad80e072c8e6ad0b48417db2bde114a4c9746feb228"}, -] - -[package.extras] -lint = ["docutils-stubs", "flake8", "mypy"] -test = ["pytest"] - -[[package]] -name = "sphinxcontrib-devhelp" -version = "1.0.2" -description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." -optional = true -python-versions = ">=3.5" -files = [ - {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"}, -] - -[package.extras] -lint = ["docutils-stubs", "flake8", "mypy"] -test = ["pytest"] - -[[package]] -name = "sphinxcontrib-htmlhelp" -version = "2.0.1" -description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" -optional = true -python-versions = ">=3.8" -files = [ - {file = "sphinxcontrib-htmlhelp-2.0.1.tar.gz", hash = "sha256:0cbdd302815330058422b98a113195c9249825d681e18f11e8b1f78a2f11efff"}, - {file = "sphinxcontrib_htmlhelp-2.0.1-py3-none-any.whl", hash = "sha256:c38cb46dccf316c79de6e5515e1770414b797162b23cd3d06e67020e1d2a6903"}, -] - -[package.extras] -lint = ["docutils-stubs", "flake8", "mypy"] -test = ["html5lib", "pytest"] - -[[package]] -name = "sphinxcontrib-jsmath" -version = "1.0.1" -description = "A sphinx extension which renders display math in HTML via JavaScript" -optional = true -python-versions = ">=3.5" -files = [ - {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"}, -] - -[package.extras] -test = ["flake8", "mypy", "pytest"] - -[[package]] -name = "sphinxcontrib-programoutput" -version = "0.17" -description = "Sphinx extension to include program output" -optional = true -python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" -files = [ - {file = "sphinxcontrib-programoutput-0.17.tar.gz", hash = "sha256:300ee9b8caee8355d25cc74b4d1c7efd12e608d2ad165e3141d31e6fbc152b7f"}, - {file = "sphinxcontrib_programoutput-0.17-py2.py3-none-any.whl", hash = "sha256:0ef1c1d9159dbe7103077748214305eb4e0138e861feb71c0c346afc5fe97f84"}, -] - -[package.dependencies] -Sphinx = ">=1.7.0" - -[[package]] -name = "sphinxcontrib-qthelp" -version = "1.0.3" -description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." -optional = true -python-versions = ">=3.5" -files = [ - {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"}, -] - -[package.extras] -lint = ["docutils-stubs", "flake8", "mypy"] -test = ["pytest"] - -[[package]] -name = "sphinxcontrib-serializinghtml" -version = "1.1.5" -description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." -optional = true -python-versions = ">=3.5" -files = [ - {file = "sphinxcontrib-serializinghtml-1.1.5.tar.gz", hash = "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952"}, - {file = "sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd"}, -] - -[package.extras] -lint = ["docutils-stubs", "flake8", "mypy"] -test = ["pytest"] - -[[package]] -name = "termcolor" -version = "2.3.0" -description = "ANSI color formatting for output in terminal" -optional = false -python-versions = ">=3.7" -files = [ - {file = "termcolor-2.3.0-py3-none-any.whl", hash = "sha256:3afb05607b89aed0ffe25202399ee0867ad4d3cb4180d98aaf8eefa6a5f7d475"}, - {file = "termcolor-2.3.0.tar.gz", hash = "sha256:b5b08f68937f138fe92f6c089b99f1e2da0ae56c52b78bf7075fd95420fd9a5a"}, -] - -[package.extras] -tests = ["pytest", "pytest-cov"] - -[[package]] -name = "toml" -version = "0.10.2" -description = "Python Library for Tom's Obvious, Minimal Language" -optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -files = [ - {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, - {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, -] - -[[package]] -name = "tomli" -version = "2.0.1" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.7" -files = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, -] - -[[package]] -name = "tox" -version = "4.10.0" -description = "tox is a generic virtualenv management and test command line tool" -optional = false -python-versions = ">=3.8" -files = [ - {file = "tox-4.10.0-py3-none-any.whl", hash = "sha256:e4a1b1438955a6da548d69a52350054350cf6a126658c20943261c48ed6d4c92"}, - {file = "tox-4.10.0.tar.gz", hash = "sha256:e041b2165375be690aca0ec4d96360c6906451380520e4665bf274f66112be35"}, -] - -[package.dependencies] -cachetools = ">=5.3.1" -chardet = ">=5.2" -colorama = ">=0.4.6" -filelock = ">=3.12.2" -packaging = ">=23.1" -platformdirs = ">=3.10" -pluggy = ">=1.2" -pyproject-api = ">=1.5.3" -tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} -virtualenv = ">=20.24.3" - -[package.extras] -docs = ["furo (>=2023.7.26)", "sphinx (>=7.1.2)", "sphinx-argparse-cli (>=1.11.1)", "sphinx-autodoc-typehints (>=1.24)", "sphinx-copybutton (>=0.5.2)", "sphinx-inline-tabs (>=2023.4.21)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -testing = ["build[virtualenv] (>=0.10)", "covdefaults (>=2.3)", "detect-test-pollution (>=1.1.1)", "devpi-process (>=0.3.1)", "diff-cover (>=7.7)", "distlib (>=0.3.7)", "flaky (>=3.7)", "hatch-vcs (>=0.3)", "hatchling (>=1.18)", "psutil (>=5.9.5)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)", "pytest-xdist (>=3.3.1)", "re-assert (>=1.1)", "time-machine (>=2.12)", "wheel (>=0.41.1)"] - -[[package]] -name = "typing-extensions" -version = "4.7.1" -description = "Backported and Experimental Type Hints for Python 3.7+" -optional = false -python-versions = ">=3.7" -files = [ - {file = "typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36"}, - {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, -] - -[[package]] -name = "urllib3" -version = "2.0.4" -description = "HTTP library with thread-safe connection pooling, file post, and more." -optional = false -python-versions = ">=3.7" -files = [ - {file = "urllib3-2.0.4-py3-none-any.whl", hash = "sha256:de7df1803967d2c2a98e4b11bb7d6bd9210474c46e8a0401514e3a42a75ebde4"}, - {file = "urllib3-2.0.4.tar.gz", hash = "sha256:8d22f86aae8ef5e410d4f539fde9ce6b2113a001bb4d189e0aed70642d602b11"}, -] - -[package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] -secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] - -[[package]] -name = "virtualenv" -version = "20.24.3" -description = "Virtual Python Environment builder" -optional = false -python-versions = ">=3.7" -files = [ - {file = "virtualenv-20.24.3-py3-none-any.whl", hash = "sha256:95a6e9398b4967fbcb5fef2acec5efaf9aa4972049d9ae41f95e0972a683fd02"}, - {file = "virtualenv-20.24.3.tar.gz", hash = "sha256:e5c3b4ce817b0b328af041506a2a299418c98747c4b1e68cb7527e74ced23efc"}, -] - -[package.dependencies] -distlib = ">=0.3.7,<1" -filelock = ">=3.12.2,<4" -platformdirs = ">=3.9.1,<4" - -[package.extras] -docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx (>=7.0.1)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] -test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] - -[[package]] -name = "voluptuous" -version = "0.13.1" -description = "" -optional = false -python-versions = "*" -files = [ - {file = "voluptuous-0.13.1-py3-none-any.whl", hash = "sha256:4b838b185f5951f2d6e8752b68fcf18bd7a9c26ded8f143f92d6d28f3921a3e6"}, - {file = "voluptuous-0.13.1.tar.gz", hash = "sha256:e8d31c20601d6773cb14d4c0f42aee29c6821bbd1018039aac7ac5605b489723"}, -] - -[[package]] -name = "xdoctest" -version = "1.1.1" -description = "A rewrite of the builtin doctest module" -optional = false -python-versions = ">=3.6" -files = [ - {file = "xdoctest-1.1.1-py3-none-any.whl", hash = "sha256:d59d4ed91cb92e4430ef0ad1b134a2bef02adff7d2fb9c9f057547bee44081a2"}, - {file = "xdoctest-1.1.1.tar.gz", hash = "sha256:2eac8131bdcdf2781b4e5a62d6de87f044b730cc8db8af142a51bb29c245e779"}, -] - -[package.dependencies] -six = "*" - -[package.extras] -all = ["IPython", "IPython", "Pygments", "Pygments", "attrs", "codecov", "colorama", "debugpy", "debugpy", "debugpy", "debugpy", "debugpy", "ipykernel", "ipykernel", "ipython-genutils", "jedi", "jinja2", "jupyter-client", "jupyter-client", "jupyter-core", "nbconvert", "pyflakes", "pytest", "pytest", "pytest", "pytest-cov", "six", "tomli", "typing"] -all-strict = ["IPython (==7.10.0)", "IPython (==7.23.1)", "Pygments (==2.0.0)", "Pygments (==2.4.1)", "attrs (==19.2.0)", "codecov (==2.0.15)", "colorama (==0.4.1)", "debugpy (==1.0.0)", "debugpy (==1.0.0)", "debugpy (==1.0.0)", "debugpy (==1.3.0)", "debugpy (==1.6.0)", "ipykernel (==5.2.0)", "ipykernel (==6.0.0)", "ipython-genutils (==0.2.0)", "jedi (==0.16)", "jinja2 (==3.0.0)", "jupyter-client (==6.1.5)", "jupyter-client (==7.0.0)", "jupyter-core (==4.7.0)", "nbconvert (==6.0.0)", "pyflakes (==2.2.0)", "pytest (==4.6.0)", "pytest (==4.6.0)", "pytest (==6.2.5)", "pytest-cov (==3.0.0)", "six (==1.11.0)", "tomli (==0.2.0)", "typing (==3.7.4)"] -colors = ["Pygments", "Pygments", "colorama"] -jupyter = ["IPython", "IPython", "attrs", "debugpy", "debugpy", "debugpy", "debugpy", "debugpy", "ipykernel", "ipykernel", "ipython-genutils", "jedi", "jinja2", "jupyter-client", "jupyter-client", "jupyter-core", "nbconvert"] -optional = ["IPython", "IPython", "Pygments", "Pygments", "attrs", "colorama", "debugpy", "debugpy", "debugpy", "debugpy", "debugpy", "ipykernel", "ipykernel", "ipython-genutils", "jedi", "jinja2", "jupyter-client", "jupyter-client", "jupyter-core", "nbconvert", "pyflakes", "tomli"] -optional-strict = ["IPython (==7.10.0)", "IPython (==7.23.1)", "Pygments (==2.0.0)", "Pygments (==2.4.1)", "attrs (==19.2.0)", "colorama (==0.4.1)", "debugpy (==1.0.0)", "debugpy (==1.0.0)", "debugpy (==1.0.0)", "debugpy (==1.3.0)", "debugpy (==1.6.0)", "ipykernel (==5.2.0)", "ipykernel (==6.0.0)", "ipython-genutils (==0.2.0)", "jedi (==0.16)", "jinja2 (==3.0.0)", "jupyter-client (==6.1.5)", "jupyter-client (==7.0.0)", "jupyter-core (==4.7.0)", "nbconvert (==6.0.0)", "pyflakes (==2.2.0)", "tomli (==0.2.0)"] -runtime-strict = ["six (==1.11.0)"] -tests = ["codecov", "pytest", "pytest", "pytest", "pytest-cov", "typing"] -tests-binary = ["cmake", "cmake", "ninja", "ninja", "pybind11", "pybind11", "scikit-build", "scikit-build"] -tests-binary-strict = ["cmake (==3.21.2)", "cmake (==3.25.0)", "ninja (==1.10.2)", "ninja (==1.11.1)", "pybind11 (==2.10.3)", "pybind11 (==2.7.1)", "scikit-build (==0.11.1)", "scikit-build (==0.16.1)"] -tests-strict = ["codecov (==2.0.15)", "pytest (==4.6.0)", "pytest (==4.6.0)", "pytest (==6.2.5)", "pytest-cov (==3.0.0)", "typing (==3.7.4)"] - -[[package]] -name = "zipp" -version = "3.16.2" -description = "Backport of pathlib-compatible object wrapper for zip files" -optional = true -python-versions = ">=3.8" -files = [ - {file = "zipp-3.16.2-py3-none-any.whl", hash = "sha256:679e51dd4403591b2d6838a48de3d283f3d188412a9782faadf845f298736ba0"}, - {file = "zipp-3.16.2.tar.gz", hash = "sha256:ebc15946aa78bd63458992fc81ec3b6f7b1e92d51c35e6de1c3804e73b799147"}, -] - -[package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"] - -[extras] -docs = ["docutils", "myst-parser", "sphinx", "sphinx_rtd_theme", "sphinxcontrib-programoutput"] -speedups = ["kasa-crypt", "orjson"] - -[metadata] -lock-version = "2.0" -python-versions = "^3.8" -content-hash = "097b5cdfc1d2ccf3e89d306242f0f3a9a84e53c039f939df4e55d13c471f6084" diff --git a/pyproject.toml b/pyproject.toml index 7dcbd094..9dc265c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,122 +1,198 @@ -[tool.poetry] +[project] name = "python-kasa" -version = "0.6.0.dev2" -description = "Python API for TP-Link Kasa Smarthome devices" -license = "GPL-3.0-or-later" -authors = ["python-kasa developers"] -repository = "https://github.com/python-kasa/python-kasa" +version = "0.8.1" +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" } -] -include = [ - { path= "CHANGELOG.md", format = "sdist" } +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", ] -[tool.poetry.urls] +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" -[tool.poetry.scripts] -kasa = "kasa.cli:cli" +[project.scripts] +kasa = "kasa.cli.__main__:cli" -[tool.poetry.dependencies] -python = "^3.8" -anyio = "*" # see https://github.com/python-trio/asyncclick/issues/18 -asyncclick = ">=8" -pydantic = ">=1" -cryptography = ">=1.9" -async-timeout = ">=3.0.0" -httpx = ">=0.25.0" +[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.7.4", +] -# speed ups -orjson = { "version" = ">=3.9.1", optional = true } -kasa-crypt = { "version" = ">=0.2.0", optional = true } -# required only for docs -sphinx = { version = "^4", optional = true } -sphinx_rtd_theme = { version = "^0", optional = true } -sphinxcontrib-programoutput = { version = "^0", optional = true } -myst-parser = { version = "*", optional = true } -docutils = { version = ">=0.17", optional = true } +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" -[tool.poetry.group.dev.dependencies] -pytest = "*" -pytest-cov = "*" -pytest-asyncio = "*" -pytest-sugar = "*" -pre-commit = "*" -voluptuous = "*" -toml = "*" -tox = "*" -pytest-mock = "*" -codecov = "*" -xdoctest = "*" -coverage = {version = "*", extras = ["toml"]} +[tool.hatch.build.targets.sdist] +include = [ + "/kasa", + "/devtools", + "/docs", + "/tests", + "/CHANGELOG.md", +] -[tool.poetry.extras] -docs = ["sphinx", "sphinx_rtd_theme", "sphinxcontrib-programoutput", "myst-parser", "docutils"] -speedups = ["orjson", "kasa-crypt"] +[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.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" [tool.doc8] paths = ["docs"] ignore = ["D001"] ignore-path-errors = ["docs/source/index.rst;D000"] -[build-system] -requires = ["poetry-core>=1.0.0"] -build-backend = "poetry.core.masonry.api" [tool.ruff] -target-version = "py38" +target-version = "py311" + +[tool.ruff.lint] select = [ - "E", # pycodestyle + "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__` + "ANN101", # Missing type annotation for `self` + "ANN102", # Missing type annotation for `cls` in classmethod + "ANN003", # Missing type annotation for `**kwargs` + "ANN401", # allow any ] -[tool.ruff.pydocstyle] +[tool.ruff.lint.pydocstyle] convention = "pep257" -[tool.ruff.per-file-ignores] -"kasa/tests/*.py" = [ +[tool.ruff.lint.per-file-ignores] +"tests/*.py" = [ "D100", "D101", "D102", "D103", "D104", - "F401", "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/kasa/tests/__init__.py b/tests/__init__.py similarity index 100% rename from kasa/tests/__init__.py rename to tests/__init__.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..3da689c5 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import asyncio +import sys +import warnings +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +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 diff --git a/tests/device_fixtures.py b/tests/device_fixtures.py new file mode 100644 index 00000000..6679d0a5 --- /dev/null +++ b/tests/device_fixtures.py @@ -0,0 +1,548 @@ +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 = {"L530E", "L930-5"} +BULBS_SMART_LIGHT_STRIP = {"L900-5", "L900-10", "L920-5", "L930-5"} +BULBS_SMART_COLOR = {"L530E", *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", "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"} +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", + "KP100", + "KP105", + "KP115", + "KP125", + "KP401", +} +# P135 supports dimming, but its not currently support +# by the library +PLUGS_SMART = { + "P100", + "P110", + "P110M", + "P115", + "KP125M", + "EP25", + "P125M", + "TP15", +} +PLUGS = { + *PLUGS_IOT, + *PLUGS_SMART, +} +SWITCHES_IOT = { + "HS200", + "HS210", + "KS200", + "KS200M", +} +SWITCHES_SMART = { + "HS200", + "KS205", + "KS225", + "KS240", + "S500D", + "S505", + "S505D", +} +SWITCHES = {*SWITCHES_IOT, *SWITCHES_SMART} +STRIPS_IOT = {"HS107", "HS300", "KP303", "KP200", "KP400", "EP40"} +STRIPS_SMART = {"P300", "P304M", "TP25", "EP40M"} +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"} +THERMOSTATS_SMART = {"KE100"} + +WITH_EMETER_IOT = {"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) +) +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_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"}, +) +hub_smartcam = parametrize( + "hub smartcam", + device_type_filter=[DeviceType.Hub], + protocol_filter={"SMARTCAM"}, +) + + +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] + + camera_smartcam.args[1] + + hub_smartcam.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 == "SMARTCAM": + 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" + ) + if fixture_data.protocol in {"SMART", "SMART.CHILD"}: + d.protocol = FakeSmartProtocol( + fixture_data.data, fixture_data.name, verbatim=verbatim + ) + elif fixture_data.protocol == "SMARTCAM": + 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 00000000..eb843f1a --- /dev/null +++ b/tests/discovery_fixtures.py @@ -0,0 +1,346 @@ +from __future__ import annotations + +import copy +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": "192.168.1.192", + "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", "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 + + @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"] + dm = _DiscoveryMock( + ip, + 80, + 20002, + discovery_data, + fixture_data, + device_type, + encrypt_type, + https, + login_version, + ) + 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 + + 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. + """ + 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), + ) + + async def _query(self, request, retry_count: int = 3): + return await protos[self._host].query(request) + + 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("kasa.IotProtocol.query", _query) + mocker.patch("kasa.SmartProtocol.query", _query) + mocker.patch("kasa.discover._DiscoverProtocol.do_discover", mock_discover) + mocker.patch( + "socket.getaddrinfo", + # side_effect=lambda *_, **__: [(None, None, None, None, (first_ip, 0))], + side_effect=_getaddrinfo, + ) + # Only return the first discovery mock to be used for testing discover single + return discovery_mocks[first_ip] + + +@pytest.fixture( + params=filter_fixtures("discoverable", protocol_filter={"SMART", "IOT"}), + 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/kasa/tests/newfakes.py b/tests/fakeprotocol_iot.py similarity index 56% rename from kasa/tests/newfakes.py rename to tests/fakeprotocol_iot.py index 78bea334..88e34647 100644 --- a/kasa/tests/newfakes.py +++ b/tests/fakeprotocol_iot.py @@ -1,183 +1,13 @@ -import base64 import copy import logging -import re -import warnings -from json import loads as json_loads -from voluptuous import ( - REMOVE_EXTRA, - All, - Any, - Coerce, # type: ignore - Invalid, - Optional, - Range, - Schema, -) - -from ..credentials import Credentials -from ..deviceconfig import DeviceConfig -from ..exceptions import SmartDeviceException -from ..protocol import BaseTransport, TPLinkSmartHomeProtocol, _XorTransport -from ..smartprotocol import SmartProtocol +from kasa.deviceconfig import DeviceConfig +from kasa.protocols import IotProtocol +from kasa.transports.basetransport import BaseTransport _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? - "slot_id": Any(Coerce(int, Range(min=0)), None), - }, - 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(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": 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=360)), - "mode": str, - "on_off": check_int_bool, - "saturation": All(int, Range(min=0, max=100)), - "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)), - } - ), - "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=360)), - "index": int, - "saturation": All(int, Range(min=0, max=100)), - } - ], - } -) - - def get_realtime(obj, x, *args): return { "current": 0.268587, @@ -288,114 +118,151 @@ TIME_MODULE = { "index": 12, "tz_str": "test2", }, - "set_timezone": None, +} + +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, + }, } -class FakeSmartProtocol(SmartProtocol): - def __init__(self, info): +MOTION_MODULE = { + "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=FakeSmartTransport(info), + transport=FakeIotTransport(info, fixture_name, verbatim=verbatim), ) async def query(self, request, retry_count: int = 3): - """Implement query here so can still patch SmartProtocol.query.""" + """Implement query here so tests can still patch IotProtocol.query.""" resp_dict = await self._query(request, retry_count) return resp_dict -class FakeSmartTransport(BaseTransport): - def __init__(self, info): - super().__init__( - config=DeviceConfig( - "127.0.0.123", - credentials=Credentials( - username="dummy_user", - password="dummy_password", # noqa: S106 - ), - ), - ) - self.info = info - self.components = { - comp["id"]: comp["ver_code"] - for comp in self.info["component_nego"]["component_list"] - } - - @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}), - } - - async def send(self, request: str): - request_dict = json_loads(request) - method = request_dict["method"] - params = request_dict["params"] - if method == "multipleRequest": - responses = [] - for request in params["requests"]: - response = self._send_request(request) # type: ignore[arg-type] - response["method"] = request["method"] # type: ignore[index] - responses.append(response) - return {"result": {"responses": responses}, "error_code": 0} - else: - return self._send_request(request_dict) - - def _send_request(self, request_dict: dict): - method = request_dict["method"] - params = request_dict["params"] - if method == "component_nego" or method[:4] == "get_": - if method in self.info: - return {"result": self.info[method], "error_code": 0} - elif ( - missing_result := self.FIXTURE_MISSING_MAP.get(method) - ) and missing_result[0] in self.components: - warnings.warn( - UserWarning( - f"Fixture missing expected method {method}, try to regenerate" - ), - stacklevel=1, - ) - return {"result": missing_result[1], "error_code": 0} - else: - raise SmartDeviceException(f"Fixture doesn't support {method}") - elif method == "set_qs_info": - return {"error_code": 0} - elif method[:4] == "set_": - target_method = f"get_{method[4:]}" - self.info[target_method].update(params) - return {"error_code": 0} - - async def close(self) -> None: - pass - - -class FakeTransportProtocol(TPLinkSmartHomeProtocol): - def __init__(self, info): - super().__init__( - transport=_XorTransport( - config=DeviceConfig("127.0.0.123"), - ) - ) +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 - proto = copy.deepcopy(FakeTransportProtocol.baseproto) + 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: - # print("target %s" % target) if target != "discovery_result": for cmd in info[target]: - # print("initializing tgt %s cmd %s" % (target, cmd)) - proto[target][cmd] = info[target][cmd] + # 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 ( @@ -415,10 +282,15 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol): dummy_data = emeter_commands[module][etype] # print("got %s %s from dummy: %s" % (module, etype, dummy_data)) proto[module][etype] = dummy_data + return proto - # print("initialized: %s" % proto[module]) + @property + def default_port(self) -> int: + return 9999 - self.proto = proto + @property + def credentials_hash(self) -> None: + return None def set_alias(self, x, child_ids=None): if child_ids is None: @@ -475,6 +347,26 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol): 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"] @@ -502,9 +394,9 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol): def set_preferred_state(self, new_state, *args): """Implement set_preferred_state.""" - self.proto["system"]["get_sysinfo"]["preferred_state"][ - new_state["index"] - ] = new_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"] @@ -516,6 +408,19 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol): 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, @@ -539,6 +444,8 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol): }, "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, }, @@ -549,25 +456,47 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol): "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, - "smartlife.iot.common.timesetting": TIME_MODULE, + "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": {}, - "smartlife.iot.PIR": {}, + "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 query(self, request, port=9999): - proto = self.proto + 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"] @@ -586,7 +515,9 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol): return error(msg=f"command {cmd} not found") params = request[target][cmd] - _LOGGER.debug(f"Going to execute {target}.{cmd} (params: {params}).. ") + _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) @@ -611,4 +542,10 @@ class FakeTransportProtocol(TPLinkSmartHomeProtocol): for target in request: response.update(get_response_for_module(target)) - return response + 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 00000000..c0222b99 --- /dev/null +++ b/tests/fakeprotocol_smart.py @@ -0,0 +1,596 @@ +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.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 + # 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" + ) + 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 + + # When True verbatim will bypass any extra processing of missing + # methods and is used to test the fixture creation itself. + self.verbatim = verbatim + if verbatim: + self.warn_fixture_missing_methods = False + self.fix_incomplete_fixture_lists = False + + @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", + {"enable": True, "random_range": 120, "time": 180}, + ), + "get_alarm_configure": ( + "alarm", + { + "get_alarm_configure": { + "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_matter_setup_info": ( + "matter", + { + "setup_code": "00000000000", + "setup_payload": "00:0000000-0000.00.000", + }, + ), + } + + 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 + ): + 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): + hw_version = child_dev_info["hw_ver"] + sw_version = child_dev_info["fw_ver"] + sw_version = sw_version.split(" ")[0] + model = child_dev_info["model"] + region = child_dev_info.get("specs", "XX") + child_fixture_name = f"{model}({region})_{hw_version}_{sw_version}" + child_fixtures = filter_fixtures( + "Child fixture", + protocol_filter={"SMART.CHILD"}, + 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): + 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 + ) + # 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, + ) + else: + pytest.fixtures_missing_methods.setdefault( # type: ignore[attr-defined] + parent_fixture_name, set() + ).add("child_devices") + else: + warn( + f"Child is a cameraprotocol which needs to be implemented {child_info}", + stacklevel=2, + ) + # Replace parent child infos with the infos from the child fixtures so + # that updates update both + if child_infos and found_child_fixture_infos: + 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 ( + # FIXTURE_MISSING is for service calls not in place when + # SMART fixtures started to be generated + missing_result := self.FIXTURE_MISSING_MAP.get(child_method) + ) and missing_result[0] in self.components: + # Copy to info so it will work with update methods + child_device_calls[child_method] = copy.deepcopy(missing_result[1]) + result = copy.deepcopy(info[child_method]) + retval = {"result": result, "error_code": 0} + return retval + elif child_method[:4] == "set_": + target_method = f"get_{child_method[4:]}" + 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} + + 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[:4] == "get_": + if method in info: + result = copy.deepcopy(info[method]) + if result and "start_index" in result and "sum" in result: + list_key = next( + iter([key for key in result if isinstance(result[key], list)]) + ) + start_index = ( + start_index + if (params and (start_index := params.get("start_index"))) + else 0 + ) + # Fixtures generated before _handle_response_lists was implemented + # could have incomplete lists. + if ( + len(result[list_key]) < result["sum"] + and self.fix_incomplete_fixture_lists + ): + result["sum"] = len(result[list_key]) + if self.warn_fixture_missing_methods: + pytest.fixtures_missing_methods.setdefault( # type: ignore[attr-defined] + self.fixture_name, set() + ).add(f"{method} (incomplete '{list_key}' list)") + + result[list_key] = result[list_key][ + start_index : start_index + self.list_return_size + ] + return {"result": result, "error_code": 0} + + if self.verbatim: + return { + "error_code": SmartErrorCode.PARAMS_ERROR.value, + "method": method, + } + + 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]) + 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"]: + 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[:4] == "set_": + target_method = f"get_{method[4:]}" + 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 00000000..381a0a89 --- /dev/null +++ b/tests/fakeprotocol_smartcam.py @@ -0,0 +1,262 @@ +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.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, + 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) + self.child_protocols = FakeSmartTransport._get_child_protocols( + self.info, self.fixture_name, "getChildDeviceList" + ) + else: + self.info = info + # self.child_protocols = self._get_child_protocols() + self.list_return_size = list_return_size + + # Setting this flag allows tests to create dummy transports without + # full fixture info for testing specific cases like list handling etc + self.components_not_included = (components_not_included,) + if not components_not_included: + self.components = { + comp["name"]: comp["version"] + for comp in self.info["getAppComponentList"]["app_component"][ + "app_component_list" + ] + } + + @property + def default_port(self): + """Default port for the transport.""" + 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): + for key in set_keys[:-1]: + info = info[key] + info[set_keys[-1]] = value + + FIXTURE_MISSING_MAP = { + "getMatterSetupInfo": ( + "matter", + { + "setup_code": "00000000000", + "setup_payload": "00:0000000-0000.00.000", + }, + ) + } + # 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", + ], + } + + @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) + + async def _send_request(self, request_dict: dict): + method = request_dict["method"] + + info = self.info + 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 + section_key = section + section_val = skey_val + if (get_info := info.get(get_method)) and section_key in get_info: + get_info[section_key] = section_val + else: + return {"error_code": -1} + 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} + + if method in info: + params = request_dict.get("params") + result = copy.deepcopy(info[method]) + if "start_index" in result and "sum" in result: + list_key = next( + iter([key for key in result if isinstance(result[key], list)]) + ) + start_index = ( + start_index + if (params and (start_index := params.get("start_index"))) + else 0 + ) + + result[list_key] = result[list_key][ + start_index : start_index + self.list_return_size + ] + return {"result": result, "error_code": 0} + + 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 00000000..62b71228 --- /dev/null +++ b/tests/fixtureinfo.py @@ -0,0 +1,231 @@ +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_DEVICES = ( + SUPPORTED_IOT_DEVICES + + SUPPORTED_SMART_DEVICES + + SUPPORTED_SMART_CHILD_DEVICES + + SUPPORTED_SMARTCAM_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_info() -> list[FixtureInfo]: + """Return raw discovery file contents as JSON. Used for discovery tests.""" + fixture_data = [] + for file, protocol in SUPPORTED_DEVICES: + p = Path(file) + folder = Path(__file__).parent / "fixtures" + if protocol == "SMART": + folder = folder / "smart" + if protocol == "SMART.CHILD": + folder = folder / "smart/child" + p = folder / file + + with open(p) as f: + 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_info() + + +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 == "SMARTCAM": + 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/kasa/tests/fixtures/EP10(US)_1.0_1.0.2.json b/tests/fixtures/iot/EP10(US)_1.0_1.0.2.json similarity index 96% rename from kasa/tests/fixtures/EP10(US)_1.0_1.0.2.json rename to tests/fixtures/iot/EP10(US)_1.0_1.0.2.json index e40543d6..11cafb87 100644 --- a/kasa/tests/fixtures/EP10(US)_1.0_1.0.2.json +++ b/tests/fixtures/iot/EP10(US)_1.0_1.0.2.json @@ -2,7 +2,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "167 lamp", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Plug Mini", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/kasa/tests/fixtures/EP40(US)_1.0_1.0.2.json b/tests/fixtures/iot/EP40(US)_1.0_1.0.2.json similarity index 81% rename from kasa/tests/fixtures/EP40(US)_1.0_1.0.2.json rename to tests/fixtures/iot/EP40(US)_1.0_1.0.2.json index 238265a2..5be97e87 100644 --- a/kasa/tests/fixtures/EP40(US)_1.0_1.0.2.json +++ b/tests/fixtures/iot/EP40(US)_1.0_1.0.2.json @@ -1,12 +1,12 @@ { "system": { "get_sysinfo": { - "alias": "TP-LINK_Smart Plug_004F", + "alias": "#MASKED_NAME#", "child_num": 2, "children": [ { - "alias": "Zombie", - "id": "8006231E1499BAC4D4BC7EFCD4B075181E6393F200", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "type": -1 }, @@ -14,8 +14,8 @@ "state": 0 }, { - "alias": "Magic", - "id": "8006231E1499BAC4D4BC7EFCD4B075181E6393F201", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, diff --git a/tests/fixtures/iot/ES20M(US)_1.0_1.0.11.json b/tests/fixtures/iot/ES20M(US)_1.0_1.0.11.json new file mode 100644 index 00000000..6d15034f --- /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/kasa/tests/fixtures/ES20M(US)_1.0_1.0.8.json b/tests/fixtures/iot/ES20M(US)_1.0_1.0.8.json similarity index 98% rename from kasa/tests/fixtures/ES20M(US)_1.0_1.0.8.json rename to tests/fixtures/iot/ES20M(US)_1.0_1.0.8.json index bb316b83..e28301d5 100644 --- a/kasa/tests/fixtures/ES20M(US)_1.0_1.0.8.json +++ b/tests/fixtures/iot/ES20M(US)_1.0_1.0.8.json @@ -78,7 +78,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Test ES20M", + "alias": "#MASKED_NAME#", "brightness": 35, "dev_name": "Wi-Fi Smart Dimmer with sensor", "deviceId": "0000000000000000000000000000000000000000", diff --git a/tests/fixtures/iot/HS100(UK)_1.0_1.2.6.json b/tests/fixtures/iot/HS100(UK)_1.0_1.2.6.json new file mode 100644 index 00000000..fd98bd08 --- /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/kasa/tests/fixtures/HS100(UK)_4.1_1.1.0.json b/tests/fixtures/iot/HS100(UK)_4.1_1.1.0.json similarity index 59% rename from kasa/tests/fixtures/HS100(UK)_4.1_1.1.0.json rename to tests/fixtures/iot/HS100(UK)_4.1_1.1.0.json index 7caf6aeb..324e193a 100644 --- a/kasa/tests/fixtures/HS100(UK)_4.1_1.1.0.json +++ b/tests/fixtures/iot/HS100(UK)_4.1_1.1.0.json @@ -1,18 +1,21 @@ { "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "HS100(UK)", - "device_type": "IOT.SMARTPLUGSWITCH", - "factory_default": true, - "hw_ver": "4.1", - "ip": "127.0.0.123", - "mac": "00-00-00-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false - }, - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "HS100(UK)", + "device_type": "IOT.SMARTPLUGSWITCH", + "factory_default": true, + "hw_ver": "4.1", + "ip": "127.0.0.123", + "mac": "CC-32-E5-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false + }, + "owner": "00000000000000000000000000000000" + } }, "system": { "get_sysinfo": { @@ -28,7 +31,7 @@ "latitude_i": 0, "led_off": 0, "longitude_i": 0, - "mac": "00:00:00:00:00:00", + "mac": "CC:32:E5:00:00:00", "mic_type": "IOT.SMARTPLUGSWITCH", "model": "HS100(UK)", "next_action": { @@ -38,7 +41,7 @@ "oemId": "00000000000000000000000000000000", "on_time": 0, "relay_state": 0, - "rssi": -66, + "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_1.2.5.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_1.2.5.json rename to tests/fixtures/iot/HS100(US)_1.0_1.2.5.json index 1bbe29d4..1f2cad62 100644 --- a/kasa/tests/fixtures/HS100(US)_1.0_1.2.5.json +++ b/tests/fixtures/iot/HS100(US)_1.0_1.2.5.json @@ -18,7 +18,7 @@ "system": { "get_sysinfo": { "active_mode": "schedule", - "alias": "Unused 3", + "alias": "#MASKED_NAME#", "dev_name": "Wi-Fi Smart Plug", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/kasa/tests/fixtures/HS100(US)_2.0_1.5.6.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_1.5.6.json rename to tests/fixtures/iot/HS100(US)_2.0_1.5.6.json index 03dd42d5..f73d6233 100644 --- a/kasa/tests/fixtures/HS100(US)_2.0_1.5.6.json +++ b/tests/fixtures/iot/HS100(US)_2.0_1.5.6.json @@ -20,7 +20,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "3D Printer", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Plug", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/kasa/tests/fixtures/HS103(US)_1.0_1.5.7.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_1.5.7.json rename to tests/fixtures/iot/HS103(US)_1.0_1.5.7.json index e5928c3d..ec388dd3 100644 --- a/kasa/tests/fixtures/HS103(US)_1.0_1.5.7.json +++ b/tests/fixtures/iot/HS103(US)_1.0_1.5.7.json @@ -20,7 +20,7 @@ "system": { "get_sysinfo": { "active_mode": "schedule", - "alias": "Night lite", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Plug Lite", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/kasa/tests/fixtures/HS103(US)_2.1_1.1.2.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_1.1.2.json rename to tests/fixtures/iot/HS103(US)_2.1_1.1.2.json index 664845f6..a9064ac7 100644 --- a/kasa/tests/fixtures/HS103(US)_2.1_1.1.2.json +++ b/tests/fixtures/iot/HS103(US)_2.1_1.1.2.json @@ -18,7 +18,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Corner", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Plug Lite", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/kasa/tests/fixtures/HS103(US)_2.1_1.1.4.json b/tests/fixtures/iot/HS103(US)_2.1_1.1.4.json similarity index 95% rename from kasa/tests/fixtures/HS103(US)_2.1_1.1.4.json rename to tests/fixtures/iot/HS103(US)_2.1_1.1.4.json index 819c5bdd..cf7cb935 100644 --- a/kasa/tests/fixtures/HS103(US)_2.1_1.1.4.json +++ b/tests/fixtures/iot/HS103(US)_2.1_1.1.4.json @@ -2,7 +2,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Plug", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Plug Lite", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/kasa/tests/fixtures/HS105(US)_1.0_1.5.6.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_1.5.6.json rename to tests/fixtures/iot/HS105(US)_1.0_1.5.6.json index 79691004..a84c0f49 100644 --- a/kasa/tests/fixtures/HS105(US)_1.0_1.5.6.json +++ b/tests/fixtures/iot/HS105(US)_1.0_1.5.6.json @@ -20,7 +20,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Unused 1", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Plug Mini", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/kasa/tests/fixtures/HS107(US)_1.0_1.0.8.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_1.0.8.json rename to tests/fixtures/iot/HS107(US)_1.0_1.0.8.json index 046a89e9..ddc61ef8 100644 --- a/kasa/tests/fixtures/HS107(US)_1.0_1.0.8.json +++ b/tests/fixtures/iot/HS107(US)_1.0_1.0.8.json @@ -17,12 +17,12 @@ }, "system": { "get_sysinfo": { - "alias": "TP-LINK_Smart Plug_D310", + "alias": "#MASKED_NAME#", "child_num": 2, "children": [ { - "alias": "Garage Charger 1", - "id": "00", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "type": -1 }, @@ -30,8 +30,8 @@ "state": 0 }, { - "alias": "Garage Charger 2", - "id": "01", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, @@ -46,7 +46,7 @@ "hw_ver": "1.0", "latitude_i": 0, "led_off": 0, - "longitude_i": -0, + "longitude_i": 0, "mac": "00:00:00:00:00:00", "mic_type": "IOT.SMARTPLUGSWITCH", "model": "HS107(US)", diff --git a/tests/fixtures/iot/HS110(EU)_1.0_1.2.5.json b/tests/fixtures/iot/HS110(EU)_1.0_1.2.5.json new file mode 100644 index 00000000..e75b18bc --- /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 00000000..1d9b3d3e --- /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 00000000..cf5ac065 --- /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_1.5.7.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_1.5.7.json rename to tests/fixtures/iot/HS200(US)_2.0_1.5.7.json index 2fbcc65c..31e4a5f9 100644 --- a/kasa/tests/fixtures/HS200(US)_2.0_1.5.7.json +++ b/tests/fixtures/iot/HS200(US)_2.0_1.5.7.json @@ -20,7 +20,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Master Bedroom Fan", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Light Switch", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/HS200(US)_3.0_1.1.5.json b/tests/fixtures/iot/HS200(US)_3.0_1.1.5.json new file mode 100644 index 00000000..f953e7a1 --- /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 00000000..19780635 --- /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/kasa/tests/fixtures/HS200(US)_5.0_1.0.2.json b/tests/fixtures/iot/HS200(US)_5.0_1.0.2.json similarity index 96% rename from kasa/tests/fixtures/HS200(US)_5.0_1.0.2.json rename to tests/fixtures/iot/HS200(US)_5.0_1.0.2.json index fc09e6f5..44370f2e 100644 --- a/kasa/tests/fixtures/HS200(US)_5.0_1.0.2.json +++ b/tests/fixtures/iot/HS200(US)_5.0_1.0.2.json @@ -2,7 +2,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "House Fan", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Light Switch", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/kasa/tests/fixtures/HS210(US)_1.0_1.5.8.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_1.5.8.json rename to tests/fixtures/iot/HS210(US)_1.0_1.5.8.json index ced3e891..b286c53f 100644 --- a/kasa/tests/fixtures/HS210(US)_1.0_1.5.8.json +++ b/tests/fixtures/iot/HS210(US)_1.0_1.5.8.json @@ -21,7 +21,7 @@ "get_sysinfo": { "abnormal_detect": 1, "active_mode": "none", - "alias": "Garage Light", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi 3-Way Light Switch", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/HS210(US)_2.0_1.1.5.json b/tests/fixtures/iot/HS210(US)_2.0_1.1.5.json new file mode 100644 index 00000000..3478b2b5 --- /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/kasa/tests/fixtures/HS220(US)_1.0_1.5.7.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_1.5.7.json rename to tests/fixtures/iot/HS220(US)_1.0_1.5.7.json index 7c166220..3826d198 100644 --- a/kasa/tests/fixtures/HS220(US)_1.0_1.5.7.json +++ b/tests/fixtures/iot/HS220(US)_1.0_1.5.7.json @@ -28,7 +28,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Living room 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)_2.0_1.0.3.json b/tests/fixtures/iot/HS220(US)_2.0_1.0.3.json similarity index 97% rename from kasa/tests/fixtures/HS220(US)_2.0_1.0.3.json rename to tests/fixtures/iot/HS220(US)_2.0_1.0.3.json index d8ca213e..d7d0a5a2 100644 --- a/kasa/tests/fixtures/HS220(US)_2.0_1.0.3.json +++ b/tests/fixtures/iot/HS220(US)_2.0_1.0.3.json @@ -17,7 +17,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Living Room Lights", + "alias": "#MASKED_NAME#", "brightness": 100, "dev_name": "Wi-Fi Smart Dimmer", "deviceId": "0000000000000000000000000000000000000000", diff --git a/kasa/tests/fixtures/HS300(US)_1.0_1.0.10.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_1.0.10.json rename to tests/fixtures/iot/HS300(US)_1.0_1.0.10.json index a6d34957..0fc22a39 100644 --- a/kasa/tests/fixtures/HS300(US)_1.0_1.0.10.json +++ b/tests/fixtures/iot/HS300(US)_1.0_1.0.10.json @@ -22,12 +22,12 @@ }, "system": { "get_sysinfo": { - "alias": "TP-LINK_Power Strip_DAE1", + "alias": "#MASKED_NAME#", "child_num": 6, "children": [ { - "alias": "Office Monitor 1", - "id": "00", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "type": -1 }, @@ -35,8 +35,8 @@ "state": 0 }, { - "alias": "Office Monitor 2", - "id": "01", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, @@ -44,8 +44,8 @@ "state": 0 }, { - "alias": "Office Monitor 3", - "id": "02", + "alias": "#MASKED_NAME# 3", + "id": "SCRUBBED_CHILD_DEVICE_ID_3", "next_action": { "type": -1 }, @@ -53,8 +53,8 @@ "state": 0 }, { - "alias": "Office Laptop Dock", - "id": "03", + "alias": "#MASKED_NAME# 4", + "id": "SCRUBBED_CHILD_DEVICE_ID_4", "next_action": { "type": -1 }, @@ -62,8 +62,8 @@ "state": 0 }, { - "alias": "Office Desk Light", - "id": "04", + "alias": "#MASKED_NAME# 5", + "id": "SCRUBBED_CHILD_DEVICE_ID_5", "next_action": { "type": -1 }, @@ -71,8 +71,8 @@ "state": 0 }, { - "alias": "Laptop", - "id": "05", + "alias": "#MASKED_NAME# 6", + "id": "SCRUBBED_CHILD_DEVICE_ID_6", "next_action": { "type": -1 }, @@ -87,7 +87,7 @@ "hw_ver": "1.0", "latitude_i": 0, "led_off": 0, - "longitude_i": -0, + "longitude_i": 0, "mac": "00:00:00:00:00:00", "mic_type": "IOT.SMARTPLUGSWITCH", "model": "HS300(US)", diff --git a/tests/fixtures/iot/HS300(US)_1.0_1.0.21.json b/tests/fixtures/iot/HS300(US)_1.0_1.0.21.json new file mode 100644 index 00000000..a174027c --- /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 00000000..bca72089 --- /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/kasa/tests/fixtures/HS300(US)_2.0_1.0.3.json b/tests/fixtures/iot/HS300(US)_2.0_1.0.3.json similarity index 75% rename from kasa/tests/fixtures/HS300(US)_2.0_1.0.3.json rename to tests/fixtures/iot/HS300(US)_2.0_1.0.3.json index 3b99cf36..8a5b22c4 100644 --- a/kasa/tests/fixtures/HS300(US)_2.0_1.0.3.json +++ b/tests/fixtures/iot/HS300(US)_2.0_1.0.3.json @@ -11,12 +11,12 @@ }, "system": { "get_sysinfo": { - "alias": "TP-LINK_Power Strip_5C33", + "alias": "#MASKED_NAME#", "child_num": 6, "children": [ { - "alias": "Plug 1", - "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031900", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "type": -1 }, @@ -24,8 +24,8 @@ "state": 0 }, { - "alias": "Plug 2", - "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031901", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, @@ -33,8 +33,8 @@ "state": 0 }, { - "alias": "Plug 3", - "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031902", + "alias": "#MASKED_NAME# 3", + "id": "SCRUBBED_CHILD_DEVICE_ID_3", "next_action": { "type": -1 }, @@ -42,8 +42,8 @@ "state": 0 }, { - "alias": "Plug 4", - "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031903", + "alias": "#MASKED_NAME# 4", + "id": "SCRUBBED_CHILD_DEVICE_ID_4", "next_action": { "type": -1 }, @@ -51,8 +51,8 @@ "state": 0 }, { - "alias": "Plug 5", - "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031904", + "alias": "#MASKED_NAME# 5", + "id": "SCRUBBED_CHILD_DEVICE_ID_5", "next_action": { "type": -1 }, @@ -60,8 +60,8 @@ "state": 0 }, { - "alias": "Plug 6", - "id": "8006AF35494E7DB13DDE9B8F40BF2E001E77031905", + "alias": "#MASKED_NAME# 6", + "id": "SCRUBBED_CHILD_DEVICE_ID_6", "next_action": { "type": -1 }, diff --git a/kasa/tests/fixtures/KL110(US)_1.0_1.8.11.json b/tests/fixtures/iot/KL110(US)_1.0_1.8.11.json similarity index 98% rename from kasa/tests/fixtures/KL110(US)_1.0_1.8.11.json rename to tests/fixtures/iot/KL110(US)_1.0_1.8.11.json index 94c38858..89b623bd 100644 --- a/kasa/tests/fixtures/KL110(US)_1.0_1.8.11.json +++ b/tests/fixtures/iot/KL110(US)_1.0_1.8.11.json @@ -21,7 +21,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Bulb3", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/kasa/tests/fixtures/LB120(US)_1.0_mocked.json b/tests/fixtures/iot/KL120(US)_1.0_1.8.11.json similarity index 55% rename from kasa/tests/fixtures/LB120(US)_1.0_mocked.json rename to tests/fixtures/iot/KL120(US)_1.0_1.8.11.json index e9d1dce2..0bbc9886 100644 --- a/kasa/tests/fixtures/LB120(US)_1.0_mocked.json +++ b/tests/fixtures/iot/KL120(US)_1.0_1.8.11.json @@ -1,88 +1,70 @@ { - "emeter": { - "err_code": -1, - "err_msg": "module not support" - }, "smartlife.iot.common.emeter": { "get_realtime": { "err_code": 0, - "power_mw": 10800 + "power_mw": 7800 } }, - "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, - "mode": "normal", - "saturation": 0 - }, + "brightness": 70, + "color_temp": 3001, "err_code": 0, - "on_off": 0 + "hue": 0, + "mode": "normal", + "on_off": 1, + "saturation": 0 } }, "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", "dev_state": "normal", - "deviceId": "62FD818E5B66A509D571D07D0F00FA4DD6468494", + "deviceId": "0000000000000000000000000000000000000000", "disco_ver": "1.0", "err_code": 0, - "heapsize": 302452, - "hwId": "CC0588817E251DF996F1848ED331F543", + "heapsize": 292140, + "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, "light_state": { - "dft_on_state": { - "brightness": 100, - "color_temp": 2700, - "hue": 0, - "mode": "normal", - "saturation": 0 - }, - "err_code": 0, - "on_off": 0 + "brightness": 70, + "color_temp": 3001, + "hue": 0, + "mode": "normal", + "on_off": 1, + "saturation": 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": "KL120(US)", + "oemId": "00000000000000000000000000000000", "preferred_state": [ { "brightness": 100, - "color_temp": 2700, + "color_temp": 3500, "hue": 0, "index": 0, "saturation": 0 }, { - "brightness": 75, - "color_temp": 2700, + "brightness": 50, + "color_temp": 5000, "hue": 0, "index": 1, "saturation": 0 }, { - "brightness": 25, + "brightness": 50, "color_temp": 2700, "hue": 0, "index": 2, @@ -96,8 +78,8 @@ "saturation": 0 } ], - "rssi": -65, - "sw_ver": "1.1.0 Build 160630 Rel.085319" + "rssi": -45, + "sw_ver": "1.8.11 Build 191113 Rel.105336" } } } diff --git a/kasa/tests/fixtures/KL120(US)_1.0_1.8.6.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_1.8.6.json rename to tests/fixtures/iot/KL120(US)_1.0_1.8.6.json index c251f2fa..50bd202e 100644 --- a/kasa/tests/fixtures/KL120(US)_1.0_1.8.6.json +++ b/tests/fixtures/iot/KL120(US)_1.0_1.8.6.json @@ -34,11 +34,11 @@ }, "description": "Smart Wi-Fi LED Bulb with Tunable White Light", "dev_state": "normal", - "deviceId": "801200814AD69370AC59DE5501319C051AF409C3", + "deviceId": "0000000000000000000000000000000000000000", "disco_ver": "1.0", "err_code": 0, "heapsize": 290784, - "hwId": "111E35908497A05512E259BB76801E10", + "hwId": "00000000000000000000000000000000", "hw_ver": "1.0", "is_color": 0, "is_dimmable": 1, @@ -52,10 +52,10 @@ "on_off": 1, "saturation": 0 }, - "mic_mac": "D80D17150474", + "mic_mac": "D80D17000000", "mic_type": "IOT.SMARTBULB", "model": "KL120(US)", - "oemId": "1210657CD7FBDC72895644388EEFAE8B", + "oemId": "00000000000000000000000000000000", "preferred_state": [ { "brightness": 100, diff --git a/kasa/tests/fixtures/KL125(US)_1.20_1.0.5.json b/tests/fixtures/iot/KL125(US)_1.20_1.0.5.json similarity index 98% rename from kasa/tests/fixtures/KL125(US)_1.20_1.0.5.json rename to tests/fixtures/iot/KL125(US)_1.20_1.0.5.json index 1fca6924..aedcb1f6 100644 --- a/kasa/tests/fixtures/KL125(US)_1.20_1.0.5.json +++ b/tests/fixtures/iot/KL125(US)_1.20_1.0.5.json @@ -20,7 +20,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "kasa-bc01", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/kasa/tests/fixtures/KL125(US)_2.0_1.0.7.json b/tests/fixtures/iot/KL125(US)_2.0_1.0.7.json similarity index 98% rename from kasa/tests/fixtures/KL125(US)_2.0_1.0.7.json rename to tests/fixtures/iot/KL125(US)_2.0_1.0.7.json index b7fa640b..9d19ca57 100644 --- a/kasa/tests/fixtures/KL125(US)_2.0_1.0.7.json +++ b/tests/fixtures/iot/KL125(US)_2.0_1.0.7.json @@ -22,7 +22,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Test bulb 6", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/kasa/tests/fixtures/KL130(EU)_1.0_1.8.8.json b/tests/fixtures/iot/KL125(US)_4.0_1.0.5.json similarity index 60% rename from kasa/tests/fixtures/KL130(EU)_1.0_1.8.8.json rename to tests/fixtures/iot/KL125(US)_4.0_1.0.5.json index b57a01d2..b098dbda 100644 --- a/kasa/tests/fixtures/KL130(EU)_1.0_1.8.8.json +++ b/tests/fixtures/iot/KL125(US)_4.0_1.0.5.json @@ -2,24 +2,27 @@ "smartlife.iot.common.emeter": { "get_realtime": { "err_code": 0, - "power_mw": 1300 + "power_mw": 0, + "total_wh": 0 } }, "smartlife.iot.smartbulb.lightingservice": { "get_light_state": { - "brightness": 5, - "color_temp": 2700, + "dft_on_state": { + "brightness": 84, + "color_temp": 0, + "hue": 9, + "mode": "normal", + "saturation": 67 + }, "err_code": 0, - "hue": 1, - "mode": "normal", - "on_off": 1, - "saturation": 1 + "on_off": 0 } }, "system": { "get_sysinfo": { - "active_mode": "schedule", - "alias": "bedroom", + "active_mode": "none", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" @@ -29,29 +32,33 @@ "deviceId": "0000000000000000000000000000000000000000", "disco_ver": "1.0", "err_code": 0, - "heapsize": 332316, "hwId": "00000000000000000000000000000000", - "hw_ver": "1.0", + "hw_ver": "4.0", "is_color": 1, "is_dimmable": 1, "is_factory": false, "is_variable_color_temp": 1, + "latitude_i": 0, "light_state": { - "brightness": 5, - "color_temp": 2700, - "hue": 1, - "mode": "normal", - "on_off": 1, - "saturation": 1 + "dft_on_state": { + "brightness": 84, + "color_temp": 0, + "hue": 9, + "mode": "normal", + "saturation": 67 + }, + "on_off": 0 }, - "mic_mac": "000000000000", + "longitude_i": 0, + "mic_mac": "5091E3000000", "mic_type": "IOT.SMARTBULB", - "model": "KL130(EU)", + "model": "KL125(US)", + "obd_src": "tplink", "oemId": "00000000000000000000000000000000", "preferred_state": [ { - "brightness": 10, - "color_temp": 2500, + "brightness": 50, + "color_temp": 2700, "hue": 0, "index": 0, "saturation": 0 @@ -59,27 +66,28 @@ { "brightness": 100, "color_temp": 0, - "hue": 299, + "hue": 0, "index": 1, - "saturation": 95 + "saturation": 100 }, { "brightness": 100, "color_temp": 0, "hue": 120, "index": 2, - "saturation": 75 + "saturation": 100 }, { "brightness": 100, "color_temp": 0, "hue": 240, "index": 3, - "saturation": 75 + "saturation": 100 } ], - "rssi": -62, - "sw_ver": "1.8.8 Build 190613 Rel.123436" + "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 00000000..ce303462 --- /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_1.8.11.json b/tests/fixtures/iot/KL130(US)_1.0_1.8.11.json similarity index 98% rename from kasa/tests/fixtures/KL130(US)_1.0_1.8.11.json rename to tests/fixtures/iot/KL130(US)_1.0_1.8.11.json index 3ee4cb2e..d9eaaca1 100644 --- a/kasa/tests/fixtures/KL130(US)_1.0_1.8.11.json +++ b/tests/fixtures/iot/KL130(US)_1.0_1.8.11.json @@ -21,7 +21,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Bulb2", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/tests/fixtures/iot/KL135(US)_1.0_1.0.15.json b/tests/fixtures/iot/KL135(US)_1.0_1.0.15.json new file mode 100644 index 00000000..38a8805d --- /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/kasa/tests/fixtures/KL135(US)_1.0_1.0.6.json b/tests/fixtures/iot/KL135(US)_1.0_1.0.6.json similarity index 98% rename from kasa/tests/fixtures/KL135(US)_1.0_1.0.6.json rename to tests/fixtures/iot/KL135(US)_1.0_1.0.6.json index dc0ef45a..be34f9c5 100644 --- a/kasa/tests/fixtures/KL135(US)_1.0_1.0.6.json +++ b/tests/fixtures/iot/KL135(US)_1.0_1.0.6.json @@ -20,7 +20,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "KL135 Bulb", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/kasa/tests/fixtures/KL400L5(US)_1.0_1.0.5.json b/tests/fixtures/iot/KL400L5(US)_1.0_1.0.5.json similarity index 97% rename from kasa/tests/fixtures/KL400L5(US)_1.0_1.0.5.json rename to tests/fixtures/iot/KL400L5(US)_1.0_1.0.5.json index 64adf555..1bcd088b 100644 --- a/kasa/tests/fixtures/KL400L5(US)_1.0_1.0.5.json +++ b/tests/fixtures/iot/KL400L5(US)_1.0_1.0.5.json @@ -10,7 +10,7 @@ "get_sysinfo": { "LEF": 0, "active_mode": "none", - "alias": "Kl400", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/kasa/tests/fixtures/KL400L5(US)_1.0_1.0.8.json b/tests/fixtures/iot/KL400L5(US)_1.0_1.0.8.json similarity index 97% rename from kasa/tests/fixtures/KL400L5(US)_1.0_1.0.8.json rename to tests/fixtures/iot/KL400L5(US)_1.0_1.0.8.json index a737cd2a..6a15c16c 100644 --- a/kasa/tests/fixtures/KL400L5(US)_1.0_1.0.8.json +++ b/tests/fixtures/iot/KL400L5(US)_1.0_1.0.8.json @@ -10,7 +10,7 @@ "get_sysinfo": { "LEF": 0, "active_mode": "none", - "alias": "Kl400", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/kasa/tests/fixtures/KL420L5(US)_1.0_1.0.2.json b/tests/fixtures/iot/KL420L5(US)_1.0_1.0.2.json similarity index 97% rename from kasa/tests/fixtures/KL420L5(US)_1.0_1.0.2.json rename to tests/fixtures/iot/KL420L5(US)_1.0_1.0.2.json index 0d19e794..2d16adba 100644 --- a/kasa/tests/fixtures/KL420L5(US)_1.0_1.0.2.json +++ b/tests/fixtures/iot/KL420L5(US)_1.0_1.0.2.json @@ -10,7 +10,7 @@ "get_sysinfo": { "LEF": 1, "active_mode": "none", - "alias": "Kl420 test", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/kasa/tests/fixtures/KL430(UN)_2.0_1.0.8.json b/tests/fixtures/iot/KL430(UN)_2.0_1.0.8.json similarity index 98% rename from kasa/tests/fixtures/KL430(UN)_2.0_1.0.8.json rename to tests/fixtures/iot/KL430(UN)_2.0_1.0.8.json index a956575b..8a924c19 100644 --- a/kasa/tests/fixtures/KL430(UN)_2.0_1.0.8.json +++ b/tests/fixtures/iot/KL430(UN)_2.0_1.0.8.json @@ -10,7 +10,7 @@ "get_sysinfo": { "LEF": 1, "active_mode": "none", - "alias": "Bedroom light strip", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/kasa/tests/fixtures/KL430(US)_1.0_1.0.10.json b/tests/fixtures/iot/KL430(US)_1.0_1.0.10.json similarity index 50% rename from kasa/tests/fixtures/KL430(US)_1.0_1.0.10.json rename to tests/fixtures/iot/KL430(US)_1.0_1.0.10.json index f12e7d50..5bda5762 100644 --- a/kasa/tests/fixtures/KL430(US)_1.0_1.0.10.json +++ b/tests/fixtures/iot/KL430(US)_1.0_1.0.10.json @@ -7,8 +7,8 @@ "get_realtime": { "current_ma": 0, "err_code": 0, - "power_mw": 8729, - "total_wh": 21, + "power_mw": 2725, + "total_wh": 1193, "voltage_mv": 0 } }, @@ -22,8 +22,8 @@ }, "system": { "get_sysinfo": { - "active_mode": "none", - "alias": "KL430 pantry lightstrip", + "active_mode": "schedule", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" @@ -42,27 +42,66 @@ "latitude_i": 0, "length": 16, "light_state": { - "brightness": 50, - "color_temp": 3630, + "brightness": 15, + "color_temp": 2500, "hue": 0, "mode": "normal", "on_off": 1, "saturation": 0 }, "lighting_effect_state": { - "brightness": 50, + "brightness": 100, "custom": 0, "enable": 0, - "id": "", - "name": "" + "id": "bCTItKETDFfrKANolgldxfgOakaarARs", + "name": "Flicker" }, "longitude_i": 0, - "mic_mac": "CC32E5230F55", + "mic_mac": "CC32E5000000", "mic_type": "IOT.SMARTBULB", "model": "KL430(US)", "oemId": "00000000000000000000000000000000", - "preferred_state": [], - "rssi": -56, + "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 00000000..380250ff --- /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/kasa/tests/fixtures/KL430(US)_2.0_1.0.8.json b/tests/fixtures/iot/KL430(US)_2.0_1.0.8.json similarity index 97% rename from kasa/tests/fixtures/KL430(US)_2.0_1.0.8.json rename to tests/fixtures/iot/KL430(US)_2.0_1.0.8.json index e69a9dc1..c5cf550b 100644 --- a/kasa/tests/fixtures/KL430(US)_2.0_1.0.8.json +++ b/tests/fixtures/iot/KL430(US)_2.0_1.0.8.json @@ -10,7 +10,7 @@ "get_sysinfo": { "LEF": 1, "active_mode": "none", - "alias": "89 strip", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/kasa/tests/fixtures/KL430(US)_2.0_1.0.9.json b/tests/fixtures/iot/KL430(US)_2.0_1.0.9.json similarity index 97% rename from kasa/tests/fixtures/KL430(US)_2.0_1.0.9.json rename to tests/fixtures/iot/KL430(US)_2.0_1.0.9.json index d5f2eafb..2d9f7535 100644 --- a/kasa/tests/fixtures/KL430(US)_2.0_1.0.9.json +++ b/tests/fixtures/iot/KL430(US)_2.0_1.0.9.json @@ -10,7 +10,7 @@ "get_sysinfo": { "LEF": 1, "active_mode": "none", - "alias": "kl430 updated", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/kasa/tests/fixtures/KL50(US)_1.0_1.1.13.json b/tests/fixtures/iot/KL50(US)_1.0_1.1.13.json similarity index 98% rename from kasa/tests/fixtures/KL50(US)_1.0_1.1.13.json rename to tests/fixtures/iot/KL50(US)_1.0_1.1.13.json index f3e43c9a..6e30c136 100644 --- a/kasa/tests/fixtures/KL50(US)_1.0_1.1.13.json +++ b/tests/fixtures/iot/KL50(US)_1.0_1.1.13.json @@ -22,7 +22,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Kl50", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/kasa/tests/fixtures/KL60(UN)_1.0_1.1.4.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_1.1.4.json rename to tests/fixtures/iot/KL60(UN)_1.0_1.1.4.json index fa842b47..22dadaee 100644 --- a/kasa/tests/fixtures/KL60(UN)_1.0_1.1.4.json +++ b/tests/fixtures/iot/KL60(UN)_1.0_1.1.4.json @@ -32,7 +32,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "TP-LINK_Smart Bulb_9179", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" @@ -60,7 +60,7 @@ "on_off": 0 }, "longitude_i": 0, - "mic_mac": "74DA88C89179", + "mic_mac": "74DA88000000", "mic_type": "IOT.SMARTBULB", "model": "KL60(UN)", "oemId": "00000000000000000000000000000000", diff --git a/kasa/tests/fixtures/KL60(US)_1.0_1.1.13.json b/tests/fixtures/iot/KL60(US)_1.0_1.1.13.json similarity index 98% rename from kasa/tests/fixtures/KL60(US)_1.0_1.1.13.json rename to tests/fixtures/iot/KL60(US)_1.0_1.1.13.json index e52cb85c..6834d925 100644 --- a/kasa/tests/fixtures/KL60(US)_1.0_1.1.13.json +++ b/tests/fixtures/iot/KL60(US)_1.0_1.1.13.json @@ -22,7 +22,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Gold fil", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/kasa/tests/fixtures/KP100(US)_3.0_1.0.1.json b/tests/fixtures/iot/KP100(US)_3.0_1.0.1.json similarity index 95% rename from kasa/tests/fixtures/KP100(US)_3.0_1.0.1.json rename to tests/fixtures/iot/KP100(US)_3.0_1.0.1.json index fb62654b..46e9ec4e 100644 --- a/kasa/tests/fixtures/KP100(US)_3.0_1.0.1.json +++ b/tests/fixtures/iot/KP100(US)_3.0_1.0.1.json @@ -2,7 +2,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Kasa", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Plug Mini", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/KP105(UK)_1.0_1.0.5.json b/tests/fixtures/iot/KP105(UK)_1.0_1.0.5.json new file mode 100644 index 00000000..91e310d3 --- /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/kasa/tests/fixtures/KP105(UK)_1.0_1.0.7.json b/tests/fixtures/iot/KP105(UK)_1.0_1.0.7.json similarity index 86% rename from kasa/tests/fixtures/KP105(UK)_1.0_1.0.7.json rename to tests/fixtures/iot/KP105(UK)_1.0_1.0.7.json index b4a12131..a66dc903 100644 --- a/kasa/tests/fixtures/KP105(UK)_1.0_1.0.7.json +++ b/tests/fixtures/iot/KP105(UK)_1.0_1.0.7.json @@ -2,7 +2,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Uk plug", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Plug", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, @@ -13,7 +13,7 @@ "latitude_i": 0, "led_off": 0, "longitude_i": 0, - "mac": "00:00:00:00:00:00", + "mac": "D8:47:32:00:00:00", "mic_type": "IOT.SMARTPLUGSWITCH", "model": "KP105(UK)", "next_action": { @@ -24,8 +24,8 @@ "oemId": "00000000000000000000000000000000", "on_time": 0, "relay_state": 0, - "rssi": -48, - "status": "new", + "rssi": -67, + "status": "configured", "sw_ver": "1.0.7 Build 210506 Rel.153510", "updating": 0 } diff --git a/kasa/tests/fixtures/KP115(EU)_1.0_1.0.16.json b/tests/fixtures/iot/KP115(EU)_1.0_1.0.16.json similarity index 76% rename from kasa/tests/fixtures/KP115(EU)_1.0_1.0.16.json rename to tests/fixtures/iot/KP115(EU)_1.0_1.0.16.json index 790597dc..3b577bac 100644 --- a/kasa/tests/fixtures/KP115(EU)_1.0_1.0.16.json +++ b/tests/fixtures/iot/KP115(EU)_1.0_1.0.16.json @@ -1,17 +1,17 @@ { "emeter": { "get_realtime": { - "current_ma": 0, + "current_ma": 296, "err_code": 0, - "power_mw": 0, - "total_wh": 0, - "voltage_mv": 231561 + "power_mw": 63499, + "total_wh": 12068, + "voltage_mv": 230577 } }, "system": { "get_sysinfo": { "active_mode": "none", - "alias": "TP-LINK_Smart Plug_330B", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Plug Mini", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, @@ -20,9 +20,9 @@ "hw_ver": "1.0", "icon_hash": "", "latitude_i": 0, - "led_off": 0, + "led_off": 1, "longitude_i": 0, - "mac": "00:00:00:00:00:00", + "mac": "C0:06:C3:00:00:00", "mic_type": "IOT.SMARTPLUGSWITCH", "model": "KP115(EU)", "next_action": { @@ -31,9 +31,9 @@ "ntc_state": 0, "obd_src": "tplink", "oemId": "00000000000000000000000000000000", - "on_time": 197, + "on_time": 2078998, "relay_state": 1, - "rssi": -70, + "rssi": -49, "status": "new", "sw_ver": "1.0.16 Build 210205 Rel.163735", "updating": 0 diff --git a/kasa/tests/fixtures/KP115(US)_1.0_1.0.17.json b/tests/fixtures/iot/KP115(US)_1.0_1.0.17.json similarity index 96% rename from kasa/tests/fixtures/KP115(US)_1.0_1.0.17.json rename to tests/fixtures/iot/KP115(US)_1.0_1.0.17.json index afb5a5fe..fb5efac8 100644 --- a/kasa/tests/fixtures/KP115(US)_1.0_1.0.17.json +++ b/tests/fixtures/iot/KP115(US)_1.0_1.0.17.json @@ -11,7 +11,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Test plug", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Plug Mini", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/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 00000000..f073e792 --- /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/kasa/tests/fixtures/KP125(US)_1.0_1.0.6.json b/tests/fixtures/iot/KP125(US)_1.0_1.0.6.json similarity index 96% rename from kasa/tests/fixtures/KP125(US)_1.0_1.0.6.json rename to tests/fixtures/iot/KP125(US)_1.0_1.0.6.json index cb32e7c6..2bb0d21e 100644 --- a/kasa/tests/fixtures/KP125(US)_1.0_1.0.6.json +++ b/tests/fixtures/iot/KP125(US)_1.0_1.0.6.json @@ -11,7 +11,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Test plug", + "alias": "#MASKED_NAME#", "dev_name": "Smart Wi-Fi Plug Mini", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/kasa/tests/fixtures/KP200(US)_3.0_1.0.3.json b/tests/fixtures/iot/KP200(US)_3.0_1.0.3.json similarity index 81% rename from kasa/tests/fixtures/KP200(US)_3.0_1.0.3.json rename to tests/fixtures/iot/KP200(US)_3.0_1.0.3.json index fef495d6..40a57fd5 100644 --- a/kasa/tests/fixtures/KP200(US)_3.0_1.0.3.json +++ b/tests/fixtures/iot/KP200(US)_3.0_1.0.3.json @@ -1,12 +1,12 @@ { "system": { "get_sysinfo": { - "alias": "TP-LINK_Smart Plug_C2D6", + "alias": "#MASKED_NAME#", "child_num": 2, "children": [ { - "alias": "One ", - "id": "80066788DFFFD572D9F2E4A5A6847669213E039F00", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "type": -1 }, @@ -14,8 +14,8 @@ "state": 1 }, { - "alias": "Two ", - "id": "80066788DFFFD572D9F2E4A5A6847669213E039F01", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, diff --git a/kasa/tests/fixtures/KP303(UK)_1.0_1.0.3.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_1.0.3.json rename to tests/fixtures/iot/KP303(UK)_1.0_1.0.3.json index 40a6a259..b5c6a105 100644 --- a/kasa/tests/fixtures/KP303(UK)_1.0_1.0.3.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/kasa/tests/fixtures/KP303(US)_2.0_1.0.3.json b/tests/fixtures/iot/KP303(US)_2.0_1.0.3.json similarity index 78% rename from kasa/tests/fixtures/KP303(US)_2.0_1.0.3.json rename to tests/fixtures/iot/KP303(US)_2.0_1.0.3.json index 96c2f8c9..a9590557 100644 --- a/kasa/tests/fixtures/KP303(US)_2.0_1.0.3.json +++ b/tests/fixtures/iot/KP303(US)_2.0_1.0.3.json @@ -1,12 +1,12 @@ { "system": { "get_sysinfo": { - "alias": "TP-LINK_Power Strip_BDF6", + "alias": "#MASKED_NAME#", "child_num": 3, "children": [ { - "alias": "Plug 1", - "id": "800681855E0E9AEF096F4891B3DC88C71E59F42E00", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "type": -1 }, @@ -14,8 +14,8 @@ "state": 0 }, { - "alias": "Plug 2", - "id": "800681855E0E9AEF096F4891B3DC88C71E59F42E01", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, @@ -23,8 +23,8 @@ "state": 0 }, { - "alias": "Plug 3", - "id": "800681855E0E9AEF096F4891B3DC88C71E59F42E02", + "alias": "#MASKED_NAME# 3", + "id": "SCRUBBED_CHILD_DEVICE_ID_3", "next_action": { "type": -1 }, diff --git a/tests/fixtures/iot/KP303(US)_2.0_1.0.9.json b/tests/fixtures/iot/KP303(US)_2.0_1.0.9.json new file mode 100644 index 00000000..333df3f6 --- /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_1.0.10.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_1.0.10.json rename to tests/fixtures/iot/KP400(US)_1.0_1.0.10.json index afdb7bfc..cd09a434 100644 --- a/kasa/tests/fixtures/KP400(US)_1.0_1.0.10.json +++ b/tests/fixtures/iot/KP400(US)_1.0_1.0.10.json @@ -17,12 +17,12 @@ }, "system": { "get_sysinfo": { - "alias": "TP-LINK_Smart Plug_2ECE", + "alias": "#MASKED_NAME#", "child_num": 2, "children": [ { - "alias": "Rope", - "id": "00", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "action": 1, "schd_sec": 69240, @@ -32,8 +32,8 @@ "state": 0 }, { - "alias": "Plug 2", - "id": "01", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, diff --git a/kasa/tests/fixtures/KP400(US)_2.0_1.0.6.json b/tests/fixtures/iot/KP400(US)_2.0_1.0.6.json similarity index 81% rename from kasa/tests/fixtures/KP400(US)_2.0_1.0.6.json rename to tests/fixtures/iot/KP400(US)_2.0_1.0.6.json index 23cd22d1..3f838a91 100644 --- a/kasa/tests/fixtures/KP400(US)_2.0_1.0.6.json +++ b/tests/fixtures/iot/KP400(US)_2.0_1.0.6.json @@ -1,12 +1,12 @@ { "system": { "get_sysinfo": { - "alias": "TP-LINK_Smart Plug_DC2A", + "alias": "#MASKED_NAME#", "child_num": 2, "children": [ { - "alias": "Anc ", - "id": "8006B8E953CC4149E2B13AA27E0D18EF1DCFBF3400", + "alias": "#MASKED_NAME# 1", + "id": "SCRUBBED_CHILD_DEVICE_ID_1", "next_action": { "type": -1 }, @@ -14,8 +14,8 @@ "state": 1 }, { - "alias": "Plug 2", - "id": "8006B8E953CC4149E2B13AA27E0D18EF1DCFBF3401", + "alias": "#MASKED_NAME# 2", + "id": "SCRUBBED_CHILD_DEVICE_ID_2", "next_action": { "type": -1 }, diff --git a/tests/fixtures/iot/KP400(US)_3.0_1.0.3.json b/tests/fixtures/iot/KP400(US)_3.0_1.0.3.json new file mode 100644 index 00000000..ec1c37f3 --- /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 00000000..5a60a400 --- /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/kasa/tests/fixtures/KP401(US)_1.0_1.0.0.json b/tests/fixtures/iot/KP401(US)_1.0_1.0.0.json similarity index 96% rename from kasa/tests/fixtures/KP401(US)_1.0_1.0.0.json rename to tests/fixtures/iot/KP401(US)_1.0_1.0.0.json index 644c4e5f..f3006cf4 100644 --- a/kasa/tests/fixtures/KP401(US)_1.0_1.0.0.json +++ b/tests/fixtures/iot/KP401(US)_1.0_1.0.0.json @@ -2,7 +2,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Kp401", + "alias": "#MASKED_NAME#", "dev_name": "Smart Outdoor Plug", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/kasa/tests/fixtures/KP405(US)_1.0_1.0.5.json b/tests/fixtures/iot/KP405(US)_1.0_1.0.5.json similarity index 97% rename from kasa/tests/fixtures/KP405(US)_1.0_1.0.5.json rename to tests/fixtures/iot/KP405(US)_1.0_1.0.5.json index ad6357f3..806bdc27 100644 --- a/kasa/tests/fixtures/KP405(US)_1.0_1.0.5.json +++ b/tests/fixtures/iot/KP405(US)_1.0_1.0.5.json @@ -15,7 +15,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Porch Lights", + "alias": "#MASKED_NAME#", "brightness": 50, "dev_name": "Kasa Smart Wi-Fi Outdoor Plug-In Dimmer", "deviceId": "0000000000000000000000000000000000000000", diff --git a/tests/fixtures/iot/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 00000000..d2431bfd --- /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 00000000..4fc94890 --- /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 00000000..f9498ae9 --- /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 00000000..3eb480c3 --- /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 00000000..aabbdb06 --- /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/kasa/tests/fixtures/KS200M(US)_1.0_1.0.8.json b/tests/fixtures/iot/KS200M(US)_1.0_1.0.8.json similarity index 98% rename from kasa/tests/fixtures/KS200M(US)_1.0_1.0.8.json rename to tests/fixtures/iot/KS200M(US)_1.0_1.0.8.json index 3806895b..719dab2e 100644 --- a/kasa/tests/fixtures/KS200M(US)_1.0_1.0.8.json +++ b/tests/fixtures/iot/KS200M(US)_1.0_1.0.8.json @@ -66,7 +66,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Test KS200M", + "alias": "#MASKED_NAME#", "dev_name": "Smart Light Switch with PIR", "deviceId": "0000000000000000000000000000000000000000", "err_code": 0, diff --git a/tests/fixtures/iot/KS220(US)_1.0_1.0.13.json b/tests/fixtures/iot/KS220(US)_1.0_1.0.13.json new file mode 100644 index 00000000..debdd722 --- /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/kasa/tests/fixtures/KS220M(US)_1.0_1.0.4.json b/tests/fixtures/iot/KS220M(US)_1.0_1.0.4.json similarity index 98% rename from kasa/tests/fixtures/KS220M(US)_1.0_1.0.4.json rename to tests/fixtures/iot/KS220M(US)_1.0_1.0.4.json index 40da46fd..3dceb322 100644 --- a/kasa/tests/fixtures/KS220M(US)_1.0_1.0.4.json +++ b/tests/fixtures/iot/KS220M(US)_1.0_1.0.4.json @@ -78,7 +78,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Garage Entryway Lights", + "alias": "#MASKED_NAME#", "brightness": 100, "dev_name": "Wi-Fi Smart Dimmer with sensor", "deviceId": "0000000000000000000000000000000000000000", diff --git a/kasa/tests/fixtures/KS230(US)_1.0_1.0.14.json b/tests/fixtures/iot/KS230(US)_1.0_1.0.14.json similarity index 97% rename from kasa/tests/fixtures/KS230(US)_1.0_1.0.14.json rename to tests/fixtures/iot/KS230(US)_1.0_1.0.14.json index a9e529bc..8876a1af 100644 --- a/kasa/tests/fixtures/KS230(US)_1.0_1.0.14.json +++ b/tests/fixtures/iot/KS230(US)_1.0_1.0.14.json @@ -14,7 +14,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "Test KS230", + "alias": "#MASKED_NAME#", "brightness": 60, "dc_state": 0, "dev_name": "Wi-Fi Smart 3-Way Dimmer", diff --git a/kasa/tests/fixtures/LB110(US)_1.0_1.8.11.json b/tests/fixtures/iot/LB110(US)_1.0_1.8.11.json similarity index 98% rename from kasa/tests/fixtures/LB110(US)_1.0_1.8.11.json rename to tests/fixtures/iot/LB110(US)_1.0_1.8.11.json index ec49e91b..8df62f23 100644 --- a/kasa/tests/fixtures/LB110(US)_1.0_1.8.11.json +++ b/tests/fixtures/iot/LB110(US)_1.0_1.8.11.json @@ -21,7 +21,7 @@ "system": { "get_sysinfo": { "active_mode": "none", - "alias": "TP-LINK_Smart Bulb_43EC", + "alias": "#MASKED_NAME#", "ctrl_protocols": { "name": "Linkie", "version": "1.0" diff --git a/tests/fixtures/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 00000000..2da0d5f3 --- /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 00000000..559e834b --- /dev/null +++ b/tests/fixtures/serialization/deviceconfig_camera-aes-https.json @@ -0,0 +1,10 @@ +{ + "host": "127.0.0.1", + "timeout": 5, + "connection_type": { + "device_family": "SMART.IPCAMERA", + "encryption_type": "AES", + "https": true + }, + "uses_http": false +} diff --git a/tests/fixtures/serialization/deviceconfig_plug-klap.json b/tests/fixtures/serialization/deviceconfig_plug-klap.json new file mode 100644 index 00000000..ef42bb2f --- /dev/null +++ b/tests/fixtures/serialization/deviceconfig_plug-klap.json @@ -0,0 +1,11 @@ +{ + "host": "127.0.0.1", + "timeout": 5, + "connection_type": { + "device_family": "SMART.TAPOPLUG", + "encryption_type": "KLAP", + "https": false, + "login_version": 2 + }, + "uses_http": false +} diff --git a/tests/fixtures/serialization/deviceconfig_plug-xor.json b/tests/fixtures/serialization/deviceconfig_plug-xor.json new file mode 100644 index 00000000..78cc05a9 --- /dev/null +++ b/tests/fixtures/serialization/deviceconfig_plug-xor.json @@ -0,0 +1,10 @@ +{ + "host": "127.0.0.1", + "timeout": 5, + "connection_type": { + "device_family": "IOT.SMARTPLUGSWITCH", + "encryption_type": "XOR", + "https": false + }, + "uses_http": false +} diff --git a/kasa/tests/fixtures/smart/EP25(US)_2.6_1.0.1.json b/tests/fixtures/smart/EP25(US)_2.6_1.0.1.json similarity index 94% rename from kasa/tests/fixtures/smart/EP25(US)_2.6_1.0.1.json rename to tests/fixtures/smart/EP25(US)_2.6_1.0.1.json index 61e12b25..e83c6221 100644 --- a/kasa/tests/fixtures/smart/EP25(US)_2.6_1.0.1.json +++ b/tests/fixtures/smart/EP25(US)_2.6_1.0.1.json @@ -84,21 +84,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "EP25(US)", - "device_type": "SMART.KASAPLUG", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "00-00-00-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "EP25(US)", + "device_type": "SMART.KASAPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "00-00-00-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/EP25(US)_2.6_1.0.2.json b/tests/fixtures/smart/EP25(US)_2.6_1.0.2.json new file mode 100644 index 00000000..4aebbe0e --- /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 00000000..9eef29dc --- /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(EU)_1.0_1.2.3.json b/tests/fixtures/smart/H100(EU)_1.0_1.2.3.json new file mode 100644 index 00000000..ba09016a --- /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 00000000..8173333a --- /dev/null +++ b/tests/fixtures/smart/H100(EU)_1.0_1.5.10.json @@ -0,0 +1,550 @@ +{ + "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_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 00000000..fadb35d2 --- /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 00000000..f17269cc --- /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 00000000..99818984 --- /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 00000000..0f24be14 --- /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 00000000..53684a58 --- /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 00000000..c0eeb89b --- /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/kasa/tests/fixtures/smart/KP125M(US)_1.0_1.1.3.json b/tests/fixtures/smart/KP125M(US)_1.0_1.1.3.json similarity index 84% rename from kasa/tests/fixtures/smart/KP125M(US)_1.0_1.1.3.json rename to tests/fixtures/smart/KP125M(US)_1.0_1.1.3.json index c7b6ecb9..41a34cb3 100644 --- a/kasa/tests/fixtures/smart/KP125M(US)_1.0_1.1.3.json +++ b/tests/fixtures/smart/KP125M(US)_1.0_1.1.3.json @@ -84,21 +84,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "KP125M(US)", - "device_type": "SMART.KASAPLUG", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "00-00-00-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KP125M(US)", + "device_type": "SMART.KASAPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "00-00-00-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_current_power": { "current_power": 17 @@ -124,7 +127,7 @@ "longitude": 0, "mac": "00-00-00-00-00-00", "model": "KP125M", - "nickname": "IyNNQVNLRUROQU1FIyM=", + "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", "on_time": 5332, "overheated": false, @@ -133,7 +136,7 @@ "rssi": -62, "signal_level": 2, "specs": "", - "ssid": "IyNNQVNLRUROQU1FIyM=", + "ssid": "I01BU0tFRF9TU0lEIw==", "time_diff": -360, "type": "SMART.KASAPLUG" }, diff --git a/tests/fixtures/smart/KP125M(US)_1.0_1.2.3.json b/tests/fixtures/smart/KP125M(US)_1.0_1.2.3.json new file mode 100644 index 00000000..9878b65b --- /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/kasa/tests/fixtures/smart/KS205(US)_1.0_1.0.2.json b/tests/fixtures/smart/KS205(US)_1.0_1.0.2.json similarity index 89% rename from kasa/tests/fixtures/smart/KS205(US)_1.0_1.0.2.json rename to tests/fixtures/smart/KS205(US)_1.0_1.0.2.json index c94d4f2a..60611f33 100644 --- a/kasa/tests/fixtures/smart/KS205(US)_1.0_1.0.2.json +++ b/tests/fixtures/smart/KS205(US)_1.0_1.0.2.json @@ -80,21 +80,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "KS205(US)", - "device_type": "SMART.KASASWITCH", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "00-00-00-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KS205(US)", + "device_type": "SMART.KASASWITCH", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "00-00-00-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/KS205(US)_1.0_1.1.0.json b/tests/fixtures/smart/KS205(US)_1.0_1.1.0.json new file mode 100644 index 00000000..9f7419ec --- /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/kasa/tests/fixtures/smart/KS225(US)_1.0_1.0.2.json b/tests/fixtures/smart/KS225(US)_1.0_1.0.2.json similarity index 90% rename from kasa/tests/fixtures/smart/KS225(US)_1.0_1.0.2.json rename to tests/fixtures/smart/KS225(US)_1.0_1.0.2.json index e6945cb8..1f2d9d2b 100644 --- a/kasa/tests/fixtures/smart/KS225(US)_1.0_1.0.2.json +++ b/tests/fixtures/smart/KS225(US)_1.0_1.0.2.json @@ -96,21 +96,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "KS225(US)", - "device_type": "SMART.KASASWITCH", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "00-00-00-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "KS225(US)", + "device_type": "SMART.KASASWITCH", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "00-00-00-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/KS225(US)_1.0_1.1.0.json b/tests/fixtures/smart/KS225(US)_1.0_1.1.0.json new file mode 100644 index 00000000..61ead929 --- /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/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 00000000..15092b85 --- /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 00000000..fb6c667d --- /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 00000000..4630a977 --- /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/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 00000000..f89dfc69 --- /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 00000000..a81222e4 --- /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 00000000..523d4992 --- /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/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json b/tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json similarity index 89% rename from kasa/tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json rename to tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json index 09fd8edb..05c04522 100644 --- a/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json +++ b/tests/fixtures/smart/L530E(EU)_3.0_1.0.6.json @@ -100,21 +100,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "L530E(EU)", - "device_type": "SMART.TAPOBULB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "00-00-00-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "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, @@ -141,7 +144,7 @@ "get_device_info": { "avatar": "bulb", "brightness": 100, - "color_temp": 2500, + "color_temp": 6500, "color_temp_range": [ 2500, 6500 @@ -150,33 +153,33 @@ "re_power_type": "always_on", "state": { "brightness": 100, - "color_temp": 2500, - "hue": 31, - "saturation": 88 + "color_temp": 6500, + "hue": 9, + "saturation": 67 }, "type": "last_states" }, "device_id": "0000000000000000000000000000000000000000", - "device_on": false, + "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": 31, + "hue": 9, "hw_id": "00000000000000000000000000000000", "hw_ver": "3.0", "ip": "127.0.0.123", "lang": "en_US", "latitude": 0, "longitude": 0, - "mac": "00-00-00-00-00-00", + "mac": "5C-E9-31-00-00-00", "model": "L530", "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", "overheated": false, "region": "Europe/Berlin", "rssi": -48, - "saturation": 88, + "saturation": 67, "signal_level": 3, "specs": "", "ssid": "I01BU0tFRF9TU0lEIw==", @@ -186,23 +189,23 @@ "get_device_time": { "region": "Europe/Berlin", "time_diff": 60, - "timestamp": 1704212283 + "timestamp": 1705700000 }, "get_device_usage": { "power_usage": { - "past30": 53, - "past7": 12, - "today": 0 + "past30": 19, + "past7": 3, + "today": 3 }, "saved_power": { - "past30": 494, - "past7": 112, - "today": 0 + "past30": 179, + "past7": 20, + "today": 20 }, "time_usage": { - "past30": 547, - "past7": 124, - "today": 0 + "past30": 198, + "past7": 23, + "today": 23 } }, "get_dynamic_light_effect_rules": { diff --git a/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.1.0.json b/tests/fixtures/smart/L530E(EU)_3.0_1.1.0.json similarity index 91% rename from kasa/tests/fixtures/smart/L530E(EU)_3.0_1.1.0.json rename to tests/fixtures/smart/L530E(EU)_3.0_1.1.0.json index db3e3849..a32c0463 100644 --- a/kasa/tests/fixtures/smart/L530E(EU)_3.0_1.1.0.json +++ b/tests/fixtures/smart/L530E(EU)_3.0_1.1.0.json @@ -104,21 +104,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "L530E(EU)", - "device_type": "SMART.TAPOBULB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "00-00-00-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "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, @@ -144,7 +147,7 @@ }, "get_device_info": { "avatar": "bulb", - "brightness": 50, + "brightness": 100, "color_temp": 2700, "color_temp_range": [ 2500, @@ -153,7 +156,7 @@ "default_states": { "re_power_type": "last_states", "state": { - "brightness": 50, + "brightness": 100, "color_temp": 2700, "hue": 0, "saturation": 100 @@ -173,13 +176,13 @@ "lang": "en_US", "latitude": 0, "longitude": 0, - "mac": "00-00-00-00-00-00", + "mac": "5C-E9-31-00-00-00", "model": "L530", "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", "overheated": false, "region": "Europe/London", - "rssi": -70, + "rssi": -79, "saturation": 100, "signal_level": 1, "specs": "", @@ -190,22 +193,22 @@ "get_device_time": { "region": "Europe/London", "time_diff": 0, - "timestamp": 1703934078 + "timestamp": 1705742727 }, "get_device_usage": { "power_usage": { - "past30": 221, - "past7": 54, + "past30": 140, + "past7": 0, "today": 0 }, "saved_power": { - "past30": 1452, - "past7": 503, + "past30": 1198, + "past7": 0, "today": 0 }, "time_usage": { - "past30": 1673, - "past7": 557, + "past30": 1338, + "past7": 0, "today": 0 } }, 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 00000000..8da76d78 --- /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(US)_2.0_1.1.0.json b/tests/fixtures/smart/L530E(US)_2.0_1.1.0.json new file mode 100644 index 00000000..0c80d3a5 --- /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/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 00000000..3fb263be --- /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/kasa/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 similarity index 91% rename from kasa/tests/fixtures/smart/L900-10(EU)_1.0_1.0.17.json rename to tests/fixtures/smart/L900-10(EU)_1.0_1.0.17.json index 2900accf..816cf896 100644 --- a/kasa/tests/fixtures/smart/L900-10(EU)_1.0_1.0.17.json +++ b/tests/fixtures/smart/L900-10(EU)_1.0_1.0.17.json @@ -104,21 +104,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "L900-10(EU)", - "device_type": "SMART.TAPOBULB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "00-00-00-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "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, @@ -191,7 +194,7 @@ "name": "Sunset" }, "longitude": 0, - "mac": "00-00-00-00-00-00", + "mac": "F0-A7-31-00-00-00", "model": "L900", "music_rhythm_enable": false, "music_rhythm_mode": "single_lamp", @@ -199,7 +202,7 @@ "oem_id": "00000000000000000000000000000000", "overheated": false, "region": "Europe/London", - "rssi": -58, + "rssi": -60, "saturation": 96, "signal_level": 2, "specs": "", @@ -210,22 +213,22 @@ "get_device_time": { "region": "Europe/London", "time_diff": 0, - "timestamp": 1704909087 + "timestamp": 1705742728 }, "get_device_usage": { "power_usage": { - "past30": 15, - "past7": 0, + "past30": 94, + "past7": 79, "today": 0 }, "saved_power": { - "past30": 69, - "past7": 1, + "past30": 434, + "past7": 365, "today": 0 }, "time_usage": { - "past30": 84, - "past7": 1, + "past30": 528, + "past7": 444, "today": 0 } }, 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 00000000..5c81fd32 --- /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/kasa/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 similarity index 71% rename from kasa/tests/fixtures/smart/L900-5(EU)_1.0_1.0.17.json rename to tests/fixtures/smart/L900-5(EU)_1.0_1.0.17.json index acf96268..7c7ac420 100644 --- a/kasa/tests/fixtures/smart/L900-5(EU)_1.0_1.0.17.json +++ b/tests/fixtures/smart/L900-5(EU)_1.0_1.0.17.json @@ -104,21 +104,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "L900-5(EU)", - "device_type": "SMART.TAPOBULB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "00-00-00-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "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, @@ -141,7 +144,7 @@ "get_device_info": { "avatar": "", "brightness": 100, - "color_temp": 9000, + "color_temp": 0, "color_temp_range": [ 9000, 9000 @@ -149,18 +152,18 @@ "default_states": { "state": { "brightness": 100, - "color_temp": 9000, - "hue": 0, - "saturation": 100 + "color_temp": 0, + "hue": 354, + "saturation": 93 }, "type": "last_states" }, "device_id": "0000000000000000000000000000000000000000", - "device_on": true, + "device_on": false, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.0.17 Build 230426 Rel.153230", "has_set_location_info": false, - "hue": 0, + "hue": 354, "hw_id": "00000000000000000000000000000000", "hw_ver": "1.0", "ip": "127.0.0.123", @@ -175,7 +178,7 @@ "name": "station" }, "longitude": 0, - "mac": "00-00-00-00-00-00", + "mac": "A8-42-A1-00-00-00", "model": "L900", "music_rhythm_enable": false, "music_rhythm_mode": "single_lamp", @@ -183,8 +186,8 @@ "oem_id": "00000000000000000000000000000000", "overheated": false, "region": "Europe/Berlin", - "rssi": -50, - "saturation": 100, + "rssi": -55, + "saturation": 93, "signal_level": 2, "specs": "", "ssid": "I01BU0tFRF9TU0lEIw==", @@ -194,23 +197,23 @@ "get_device_time": { "region": "Europe/Berlin", "time_diff": 0, - "timestamp": 1704389578 + "timestamp": 1705699997 }, "get_device_usage": { "power_usage": { - "past30": 5, - "past7": 5, - "today": 5 + "past30": 15, + "past7": 0, + "today": 0 }, "saved_power": { - "past30": 17, - "past7": 17, - "today": 17 + "past30": 81, + "past7": 1, + "today": 0 }, "time_usage": { - "past30": 22, - "past7": 22, - "today": 22 + "past30": 96, + "past7": 1, + "today": 0 } }, "get_fw_download_state": { @@ -221,6 +224,84 @@ "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 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 00000000..98980a4c --- /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 00000000..3315b19b --- /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 00000000..0f845bf3 --- /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/kasa/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 similarity index 93% rename from kasa/tests/fixtures/smart/L920-5(US)_1.0_1.1.0.json rename to tests/fixtures/smart/L920-5(US)_1.0_1.1.0.json index 2ea0c69f..95e8f969 100644 --- a/kasa/tests/fixtures/smart/L920-5(US)_1.0_1.1.0.json +++ b/tests/fixtures/smart/L920-5(US)_1.0_1.1.0.json @@ -112,21 +112,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "L920-5(US)", - "device_type": "SMART.TAPOBULB", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "00-00-00-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L920-5(US)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "00-00-00-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + } }, "get_antitheft_rules": { "antitheft_rule_max_count": 1, diff --git a/tests/fixtures/smart/L920-5(US)_1.0_1.1.3.json b/tests/fixtures/smart/L920-5(US)_1.0_1.1.3.json new file mode 100644 index 00000000..992f6399 --- /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(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 00000000..c374ebc5 --- /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 00000000..2ae738cd --- /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 00000000..5347d070 --- /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 00000000..ab75faf5 --- /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/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 00000000..dd7a0360 --- /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/kasa/tests/fixtures/smart/P110(EU)_1.0_1.2.3.json b/tests/fixtures/smart/P110(EU)_1.0_1.2.3.json similarity index 85% rename from kasa/tests/fixtures/smart/P110(EU)_1.0_1.2.3.json rename to tests/fixtures/smart/P110(EU)_1.0_1.2.3.json index e73742b7..62e580fc 100644 --- a/kasa/tests/fixtures/smart/P110(EU)_1.0_1.2.3.json +++ b/tests/fixtures/smart/P110(EU)_1.0_1.2.3.json @@ -80,21 +80,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "P110(EU)", - "device_type": "SMART.TAPOPLUG", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "00-00-00-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "AES", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "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, @@ -140,7 +143,7 @@ "lang": "en_US", "latitude": 0, "longitude": 0, - "mac": "00-00-00-00-00-00", + "mac": "48-22-54-00-00-00", "model": "P110", "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", @@ -158,23 +161,23 @@ "get_device_time": { "region": "Europe/Berlin", "time_diff": 60, - "timestamp": 1704212614 + "timestamp": 1705699998 }, "get_device_usage": { "power_usage": { - "past30": 896, - "past7": 259, - "today": 0 + "past30": 1010, + "past7": 368, + "today": 46 }, "saved_power": { - "past30": 23138, - "past7": 6684, - "today": 0 + "past30": 26144, + "past7": 9536, + "today": 1218 }, "time_usage": { - "past30": 24034, - "past7": 6943, - "today": 0 + "past30": 27154, + "past7": 9904, + "today": 1264 } }, "get_electricity_price_config": { @@ -316,11 +319,11 @@ 0, 0 ], - "local_time": "2024-01-02 17:23:34", - "month_energy": 0, - "month_runtime": 0, - "today_energy": 0, - "today_runtime": 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, @@ -341,7 +344,7 @@ } }, "get_max_power": { - "max_power": 3847 + "max_power": 3904 }, "get_next_event": {}, "get_protection_power": { @@ -356,7 +359,16 @@ "sum": 0 }, "get_wireless_scan_info": { - "ap_list": [], + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], "wep_supported": false }, "qs_component_nego": { diff --git a/kasa/tests/fixtures/smart/P110(UK)_1.0_1.3.0.json b/tests/fixtures/smart/P110(UK)_1.0_1.3.0.json similarity index 86% rename from kasa/tests/fixtures/smart/P110(UK)_1.0_1.3.0.json rename to tests/fixtures/smart/P110(UK)_1.0_1.3.0.json index 22deff8c..0c7f6e83 100644 --- a/kasa/tests/fixtures/smart/P110(UK)_1.0_1.3.0.json +++ b/tests/fixtures/smart/P110(UK)_1.0_1.3.0.json @@ -88,21 +88,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "P110(UK)", - "device_type": "SMART.TAPOPLUG", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "00-00-00-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "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, @@ -137,7 +140,7 @@ ] }, "get_current_power": { - "current_power": 11 + "current_power": 0 }, "get_device_info": { "auto_off_remain_time": 0, @@ -148,7 +151,7 @@ "type": "last_states" }, "device_id": "0000000000000000000000000000000000000000", - "device_on": true, + "device_on": false, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.3.0 Build 230905 Rel.152200", "has_set_location_info": true, @@ -158,17 +161,17 @@ "lang": "en_US", "latitude": 0, "longitude": 0, - "mac": "00-00-00-00-00-00", + "mac": "48-22-54-00-00-00", "model": "P110", "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", - "on_time": 3087, + "on_time": 0, "overcurrent_status": "normal", "overheated": false, "power_protection_status": "normal", "region": "Europe/London", - "rssi": -50, - "signal_level": 2, + "rssi": -45, + "signal_level": 3, "specs": "", "ssid": "I01BU0tFRF9TU0lEIw==", "time_diff": 0, @@ -177,23 +180,23 @@ "get_device_time": { "region": "Europe/London", "time_diff": 0, - "timestamp": 1703934043 + "timestamp": 1705742730 }, "get_device_usage": { "power_usage": { - "past30": 2090, - "past7": 1171, - "today": 9 + "past30": 3205, + "past7": 0, + "today": 0 }, "saved_power": { - "past30": 31616, - "past7": 5033, - "today": 42 + "past30": 26000, + "past7": 7265, + "today": 3 }, "time_usage": { - "past30": 33706, - "past7": 6204, - "today": 51 + "past30": 29205, + "past7": 7265, + "today": 3 } }, "get_electricity_price_config": { @@ -329,17 +332,17 @@ "type": "constant" }, "get_energy_usage": { - "current_power": 11300, + "current_power": 0, "electricity_charge": [ 0, 0, 0 ], - "local_time": "2023-12-30 11:00:43", - "month_energy": 2090, - "month_runtime": 33706, - "today_energy": 9, - "today_runtime": 51 + "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, @@ -360,17 +363,17 @@ }, "get_led_info": { "led_rule": "night_mode", - "led_status": true, + "led_status": false, "night_mode": { - "end_time": 550, + "end_time": 538, "night_mode_type": "sunrise_sunset", - "start_time": 961, + "start_time": 989, "sunrise_offset": 62, "sunset_offset": 0 } }, "get_max_power": { - "max_power": 3342 + "max_power": 3379 }, "get_next_event": { "desired_states": { @@ -378,7 +381,7 @@ }, "e_time": 0, "id": "S1", - "s_time": 1703937000, + "s_time": 1705751400, "type": 1 }, "get_protection_power": { @@ -389,7 +392,7 @@ "enable": true, "rule_list": [ { - "day": 30, + "day": 20, "desired_states": { "on": true }, @@ -399,12 +402,12 @@ "enable": true, "id": "S1", "mode": "repeat", - "month": 12, + "month": 1, "s_min": 710, "s_type": "normal", "time_offset": 0, "week_day": 127, - "year": 2023 + "year": 2024 } ], "schedule_rule_max_count": 32, 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 00000000..2fea4379 --- /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 00000000..81174d7b --- /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 00000000..33d7465c --- /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 00000000..151f7300 --- /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/kasa/tests/fixtures/smart/P125M(US)_1.0_1.1.0.json b/tests/fixtures/smart/P125M(US)_1.0_1.1.0.json similarity index 85% rename from kasa/tests/fixtures/smart/P125M(US)_1.0_1.1.0.json rename to tests/fixtures/smart/P125M(US)_1.0_1.1.0.json index 812cd1ea..1e0cf7e2 100644 --- a/kasa/tests/fixtures/smart/P125M(US)_1.0_1.1.0.json +++ b/tests/fixtures/smart/P125M(US)_1.0_1.1.0.json @@ -80,21 +80,24 @@ ] }, "discovery_result": { - "device_id": "00000000000000000000000000000000", - "device_model": "P125M(US)", - "device_type": "SMART.TAPOPLUG", - "factory_default": false, - "ip": "127.0.0.123", - "is_support_iot_cloud": true, - "mac": "00-00-00-00-00-00", - "mgt_encrypt_schm": { - "encrypt_type": "KLAP", - "http_port": 80, - "is_support_https": false, - "lv": 2 - }, - "obd_src": "tplink", - "owner": "00000000000000000000000000000000" + "error_code": 0, + "result": { + "device_id": "00000000000000000000000000000000", + "device_model": "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, @@ -130,21 +133,21 @@ "device_on": true, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.1.0 Build 231009 Rel.155831", - "has_set_location_info": false, + "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", + "mac": "48-22-54-00-00-00", "model": "P125M", "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", - "on_time": 76, + "on_time": 189479, "overheated": false, "region": "Pacific/Honolulu", - "rssi": -49, + "rssi": -43, "signal_level": 3, "specs": "", "ssid": "I01BU0tFRF9TU0lEIw==", @@ -154,13 +157,13 @@ "get_device_time": { "region": "Pacific/Honolulu", "time_diff": -600, - "timestamp": 1704406945 + "timestamp": 1705991899 }, "get_device_usage": { "time_usage": { - "past30": 16892, - "past7": 4, - "today": 4 + "past30": 3163, + "past7": 3163, + "today": 1238 } }, "get_fw_download_state": { @@ -184,9 +187,9 @@ "led_rule": "always", "led_status": true, "night_mode": { - "end_time": 420, + "end_time": 427, "night_mode_type": "sunrise_sunset", - "start_time": 1140, + "start_time": 1092, "sunrise_offset": 0, "sunset_offset": 0 } 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 00000000..f1099cc7 --- /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/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 00000000..73f76e83 --- /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 00000000..e9d4b54f --- /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 00000000..eaa03a35 --- /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 00000000..398977ad --- /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/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 00000000..3e6ec48d --- /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 00000000..340bd3a1 --- /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 00000000..0c990d75 --- /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/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 00000000..8d0964b3 --- /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 00000000..b9165414 --- /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/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 00000000..cd3a241e --- /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 00000000..14bb10c9 --- /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 00000000..199d572a --- /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 00000000..9df75fd7 --- /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 00000000..fa3b7c13 --- /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 00000000..3ee20e53 --- /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 00000000..0ba6e17b --- /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/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 00000000..0103fbdc --- /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/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 00000000..0393e18b --- /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 00000000..c1196449 --- /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 00000000..43dbf731 --- /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 00000000..a08cda11 --- /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 00000000..d48875e5 --- /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 00000000..bdc4eef6 --- /dev/null +++ b/tests/fixtures/smart/child/T310(US)_1.0_1.5.0.json @@ -0,0 +1,138 @@ +{ + "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_t310", + "bind_count": 1, + "category": "subg.trigger.temp-hmdt-sensor", + "current_humidity": 49, + "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": -46, + "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.5.0 Build 230105 Rel.180832", + "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/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 00000000..4fc49b0e --- /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 00000000..7a557b8c --- /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 00000000..144cf5f6 --- /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/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 00000000..609c46be --- /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 00000000..d4de5b9f --- /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/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 00000000..b04cbd06 --- /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/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 00000000..c425da79 --- /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/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 00000000..4ef99fae --- /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(US)_1.0_1.3.6.json b/tests/fixtures/smartcam/H200(US)_1.0_1.3.6.json new file mode 100644 index 00000000..26c03793 --- /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/TC65_1.0_1.3.9.json b/tests/fixtures/smartcam/TC65_1.0_1.3.9.json new file mode 100644 index 00000000..cec6b759 --- /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 00000000..b5726982 --- /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/iot/__init__.py b/tests/iot/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/iot/modules/__init__.py b/tests/iot/modules/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/iot/modules/test_ambientlight.py b/tests/iot/modules/test_ambientlight.py new file mode 100644 index 00000000..ff2bd92c --- /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 00000000..ec7f8f83 --- /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_emeter.py b/tests/iot/modules/test_emeter.py new file mode 100644 index 00000000..54fd02b2 --- /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_motion.py b/tests/iot/modules/test_motion.py new file mode 100644 index 00000000..a2b32a87 --- /dev/null +++ b/tests/iot/modules/test_motion.py @@ -0,0 +1,58 @@ +from pytest_mock import MockerFixture + +from kasa import 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") + + await motion.set_range(custom_range=123) + query_helper.assert_called_with( + "smartlife.iot.PIR", + "set_trigger_sens", + {"index": Range.Custom.value, "value": 123}, + ) + + await motion.set_range(range=Range.Far) + query_helper.assert_called_with( + "smartlife.iot.PIR", "set_trigger_sens", {"index": Range.Far.value} + ) + + +@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 00000000..4a4ffdee --- /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/kasa/tests/test_usage.py b/tests/iot/modules/test_usage.py similarity index 96% rename from kasa/tests/test_usage.py rename to tests/iot/modules/test_usage.py index 61672ffb..7b2c0eed 100644 --- a/kasa/tests/test_usage.py +++ b/tests/iot/modules/test_usage.py @@ -1,9 +1,7 @@ import datetime from unittest.mock import Mock -import pytest - -from kasa.modules import Usage +from kasa.iot.modules import Usage def test_usage_convert_stat_data(): @@ -22,7 +20,8 @@ def test_usage_convert_stat_data(): k, v = d.popitem() assert isinstance(k, int) assert isinstance(v, int) - assert k == 4 and v == 30 + assert k == 4 + assert v == 30 def test_usage_today(): diff --git a/tests/iot/test_iotbulb.py b/tests/iot/test_iotbulb.py new file mode 100644 index 00000000..5b759c58 --- /dev/null +++ b/tests/iot/test_iotbulb.py @@ -0,0 +1,322 @@ +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), + "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 00000000..858c5fbc --- /dev/null +++ b/tests/iot/test_iotdevice.py @@ -0,0 +1,310 @@ +"""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): + await handle_turn_on(dev, turn_on) + orig_state = dev.is_on + 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 00000000..38f440e7 --- /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 00000000..23eb61dc --- /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_wallswitch.py b/tests/iot/test_wallswitch.py new file mode 100644 index 00000000..b6fd2a67 --- /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 00000000..e69de29b diff --git a/tests/protocols/test_iotprotocol.py b/tests/protocols/test_iotprotocol.py new file mode 100644 index 00000000..a2feaae3 --- /dev/null +++ b/tests/protocols/test_iotprotocol.py @@ -0,0 +1,749 @@ +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 +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"), +) +@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.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_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 00000000..7961df68 --- /dev/null +++ b/tests/protocols/test_smartprotocol.py @@ -0,0 +1,530 @@ +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.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 + + +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 00000000..e69de29b diff --git a/tests/smart/features/__init__.py b/tests/smart/features/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/smart/features/test_brightness.py b/tests/smart/features/test_brightness.py new file mode 100644 index 00000000..ff38854a --- /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 00000000..055c5b29 --- /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 00000000..e69de29b diff --git a/tests/smart/modules/test_autooff.py b/tests/smart/modules/test_autooff.py new file mode 100644 index 00000000..9bdf9e56 --- /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_childprotection.py b/tests/smart/modules/test_childprotection.py new file mode 100644 index 00000000..ad2878e5 --- /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_contact.py b/tests/smart/modules/test_contact.py new file mode 100644 index 00000000..c5c4c935 --- /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_energy.py b/tests/smart/modules/test_energy.py new file mode 100644 index 00000000..7b31d74b --- /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 00000000..9a6878e5 --- /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 00000000..e3fe5bb3 --- /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 00000000..81992398 --- /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 00000000..5e14a05b --- /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 00000000..e4475652 --- /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 00000000..81bc35c8 --- /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 00000000..c1b805e4 --- /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 00000000..d3ff8073 --- /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_motionsensor.py b/tests/smart/modules/test_motionsensor.py new file mode 100644 index 00000000..418ad51a --- /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_temperature.py b/tests/smart/modules/test_temperature.py new file mode 100644 index 00000000..c2f91ae1 --- /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 00000000..d47f19ee --- /dev/null +++ b/tests/smart/modules/test_temperaturecontrol.py @@ -0,0 +1,156 @@ +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 diff --git a/tests/smart/modules/test_triggerlogs.py b/tests/smart/modules/test_triggerlogs.py new file mode 100644 index 00000000..c1d95721 --- /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 00000000..1821e6e0 --- /dev/null +++ b/tests/smart/modules/test_waterleak.py @@ -0,0 +1,45 @@ +from datetime import datetime +from enum import Enum + +import pytest + +from kasa.smart.modules import WaterleakSensor + +from ...device_fixtures import parametrize + +waterleak = parametrize( + "has waterleak", component_filter="sensor_alarm", protocol_filter={"SMART.CHILD"} +) + + +@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, feature, prop_name, type): + """Test that features are registered and work as expected.""" + 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): + """Test waterleak features.""" + 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 diff --git a/tests/smart/test_smartdevice.py b/tests/smart/test_smartdevice.py new file mode 100644 index 00000000..549eb8ad --- /dev/null +++ b/tests/smart/test_smartdevice.py @@ -0,0 +1,548 @@ +"""Tests for SMART devices.""" + +from __future__ import annotations + +import copy +import logging +import time +from typing import Any, cast +from unittest.mock import patch + +import pytest +from freezegun.api import FrozenDateTimeFactory +from pytest_mock import MockerFixture + +from kasa import Device, DeviceType, KasaException, Module +from kasa.exceptions import DeviceError, SmartErrorCode +from kasa.protocols.smartprotocol import _ChildProtocolWrapper +from kasa.smart import SmartDevice +from kasa.smart.modules.energy import Energy +from kasa.smart.smartmodule import SmartModule +from 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 variable_temp_smart + + +@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 = {} + dev._features = {} + dev._children = {} + dev._last_update = {} + dev._last_update_time = None + + negotiate = mocker.spy(dev, "_negotiate") + initialize_modules = mocker.spy(dev, "_initialize_modules") + initialize_features = mocker.spy(dev, "_initialize_features") + + # Perform two updates and verify that initialization is only done once + await dev.update() + await dev.update() + + negotiate.assert_called_once() + assert dev._components_raw is not None + initialize_modules.assert_called_once() + assert dev.modules + initialize_features.assert_called_once() + assert dev.features + + +@device_smart +async def test_negotiate(dev: SmartDevice, mocker: MockerFixture): + """Test that the initial negotiation performs expected steps.""" + # As the fixture data is already initialized, we reset the state for testing + dev._components_raw = None + dev._children = {} + + query = mocker.spy(dev.protocol, "query") + initialize_children = mocker.spy(dev, "_initialize_children") + await dev._negotiate() + + # Check that we got the initial negotiation call + query.assert_any_call( + { + "component_nego": None, + "get_device_info": None, + "get_connect_cloud_state": None, + } + ) + assert dev._components_raw + + # Check the children are created, if device supports them + if "child_device" in dev._components: + initialize_children.assert_called_once() + query.assert_any_call( + { + "get_child_device_component_list": None, + "get_child_device_list": None, + } + ) + assert len(dev._children) == dev.internal_state["get_child_device_list"]["sum"] + + +@device_smart +async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture): + """Test that the regular update uses queries from all supported modules.""" + # We need to have some modules initialized by now + assert dev._modules + # Reset last update so all modules will query + for mod in dev._modules.values(): + mod._last_update_time = None + + device_queries: dict[SmartDevice, dict[str, Any]] = {} + for mod in dev._modules.values(): + device_queries.setdefault(mod._device, {}).update(mod.query()) + # Hubs do not query child modules by default. + if dev.device_type != Device.Type.Hub: + for child in dev.children: + for mod in child.modules.values(): + device_queries.setdefault(mod._device, {}).update(mod.query()) + + spies = {} + for device in device_queries: + spies[device] = mocker.spy(device.protocol, "query") + + await dev.update() + for device in device_queries: + if device_queries[device]: + # Need assert any here because the child device updates use the parent's protocol + spies[device].assert_any_call(device_queries[device]) + else: + spies[device].assert_not_called() + + +@device_smart +@pytest.mark.xdist_group(name="caplog") +async def test_update_module_update_delays( + dev: SmartDevice, + mocker: MockerFixture, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, +): + """Test that modules with minimum delays delay.""" + # We need to have some modules initialized by now + assert dev._modules + + new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) + await new_dev.update() + first_update_time = time.monotonic() + assert new_dev._last_update_time == first_update_time + for module in new_dev.modules.values(): + if module.query(): + assert module._last_update_time == first_update_time + + seconds = 0 + tick = 30 + while seconds <= 180: + seconds += tick + freezer.tick(tick) + + now = time.monotonic() + await new_dev.update() + for module in new_dev.modules.values(): + mod_delay = module.MINIMUM_UPDATE_INTERVAL_SECS + if module.query(): + expected_update_time = ( + now if mod_delay == 0 else now - (seconds % mod_delay) + ) + + assert ( + module._last_update_time == expected_update_time + ), f"Expected update time {expected_update_time} after {seconds} seconds for {module.name} with delay {mod_delay} got {module._last_update_time}" + + +@pytest.mark.parametrize( + ("first_update"), + [ + pytest.param(True, id="First update true"), + pytest.param(False, id="First update false"), + ], +) +@pytest.mark.parametrize( + ("error_type"), + [ + pytest.param(SmartErrorCode.PARAMS_ERROR, id="Device error"), + pytest.param(TimeoutError("Dummy timeout"), id="Query error"), + ], +) +@pytest.mark.parametrize( + ("recover"), + [ + pytest.param(True, id="recover"), + pytest.param(False, id="no recover"), + ], +) +@device_smart +@pytest.mark.xdist_group(name="caplog") +async def test_update_module_query_errors( + dev: SmartDevice, + mocker: MockerFixture, + caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, + first_update, + error_type, + recover, +): + """Test that modules that disabled / removed on query failures. + + i.e. the whole query times out rather than device returns an error. + """ + # We need to have some modules initialized by now + assert dev._modules + + SmartModule.DISABLE_AFTER_ERROR_COUNT = 2 + first_update_queries = {"get_device_info", "get_connect_cloud_state"} + + critical_modules = {Module.DeviceModule, Module.ChildDevice} + + new_dev = SmartDevice("127.0.0.1", protocol=dev.protocol) + if not first_update: + await new_dev.update() + freezer.tick( + max(module.MINIMUM_UPDATE_INTERVAL_SECS for module in dev._modules.values()) + ) + + module_queries = { + modname: q + for modname, module in dev._modules.items() + if (q := module.query()) and modname not in critical_modules + } + + async def _query(request, *args, **kwargs): + if ( + "component_nego" in request + or "get_child_device_component_list" in request + or "control_child" in request + ): + resp = await dev.protocol._query(request, *args, **kwargs) + resp["get_connect_cloud_state"] = SmartErrorCode.CLOUD_FAILED_ERROR + return resp + # Don't test for errors on get_device_info as that is likely terminal + if len(request) == 1 and "get_device_info" in request: + return await dev.protocol._query(request, *args, **kwargs) + + if isinstance(error_type, SmartErrorCode): + if len(request) == 1: + raise DeviceError("Dummy device error", error_code=error_type) + raise TimeoutError("Dummy timeout") + raise error_type + + child_protocols = { + cast(_ChildProtocolWrapper, child.protocol)._device_id: child.protocol + for child in dev.children + } + + async def _child_query(self, request, *args, **kwargs): + return await child_protocols[self._device_id]._query(request, *args, **kwargs) + + mocker.patch.object(new_dev.protocol, "query", side_effect=_query) + # children not created yet so cannot patch.object + mocker.patch( + "kasa.protocols.smartprotocol._ChildProtocolWrapper.query", new=_child_query + ) + + await new_dev.update() + + msg = f"Error querying {new_dev.host} for modules" + assert msg in caplog.text + for modname in module_queries: + mod = cast(SmartModule, new_dev.modules[modname]) + assert mod.disabled is False, f"{modname} disabled" + assert mod.update_interval == mod.UPDATE_INTERVAL_AFTER_ERROR_SECS + for mod_query in module_queries[modname]: + if not first_update or mod_query not in first_update_queries: + msg = f"Error querying {new_dev.host} individually for module query '{mod_query}" + assert msg in caplog.text + + # Query again should not run for the modules + caplog.clear() + await new_dev.update() + for modname in module_queries: + mod = cast(SmartModule, new_dev.modules[modname]) + assert mod.disabled is False, f"{modname} disabled" + + freezer.tick(SmartModule.UPDATE_INTERVAL_AFTER_ERROR_SECS) + + caplog.clear() + + if recover: + mocker.patch.object( + new_dev.protocol, "query", side_effect=new_dev.protocol._query + ) + mocker.patch( + "kasa.protocols.smartprotocol._ChildProtocolWrapper.query", + new=_ChildProtocolWrapper._query, + ) + + await new_dev.update() + msg = f"Error querying {new_dev.host} for modules" + if not recover: + assert msg in caplog.text + for modname in module_queries: + mod = cast(SmartModule, new_dev.modules[modname]) + if not recover: + assert mod.disabled is True, f"{modname} not disabled" + assert mod._error_count == 2 + assert mod._last_update_error + for mod_query in module_queries[modname]: + if not first_update or mod_query not in first_update_queries: + msg = f"Error querying {new_dev.host} individually for module query '{mod_query}" + assert msg in caplog.text + # Test one of the raise_if_update_error + if mod.name == "Energy": + emod = cast(Energy, mod) + with pytest.raises(KasaException, match="Module update error"): + assert emod.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() == {} diff --git a/tests/smartcam/__init__.py b/tests/smartcam/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/smartcam/modules/__init__.py b/tests/smartcam/modules/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/smartcam/modules/test_alarm.py b/tests/smartcam/modules/test_alarm.py new file mode 100644 index 00000000..50e0b5b3 --- /dev/null +++ b/tests/smartcam/modules/test_alarm.py @@ -0,0 +1,159 @@ +"""Tests for smart camera devices.""" + +from __future__ import annotations + +import pytest + +from kasa import Device +from kasa.smartcam.modules.alarm import ( + DURATION_MAX, + DURATION_MIN, + VOLUME_MAX, + VOLUME_MIN, +) +from kasa.smartcam.smartcammodule import SmartCamModule + +from ...conftest import hub_smartcam + + +@hub_smartcam +async def test_alarm(dev: Device): + """Test device alarm.""" + alarm = dev.modules.get(SmartCamModule.SmartCamAlarm) + 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 + + 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(SmartCamModule.SmartCamAlarm) + 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(SmartCamModule.SmartCamAlarm) + 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_babycrydetection.py b/tests/smartcam/modules/test_babycrydetection.py new file mode 100644 index 00000000..89ff5ac4 --- /dev/null +++ b/tests/smartcam/modules/test_babycrydetection.py @@ -0,0 +1,45 @@ +"""Tests for smartcam baby cry detection module.""" + +from __future__ import annotations + +from kasa import Device +from kasa.smartcam.smartcammodule import SmartCamModule + +from ...device_fixtures import parametrize + +babycrydetection = parametrize( + "has babycry detection", + component_filter="babyCryDetection", + protocol_filter={"SMARTCAM"}, +) + + +@babycrydetection +async def test_babycrydetection(dev: Device): + """Test device babycry detection.""" + babycry = dev.modules.get(SmartCamModule.SmartCamBabyCryDetection) + assert babycry + + bcde_feat = dev.features.get("baby_cry_detection") + assert bcde_feat + + original_enabled = babycry.enabled + + try: + await babycry.set_enabled(not original_enabled) + await dev.update() + assert babycry.enabled is not original_enabled + assert bcde_feat.value is not original_enabled + + await babycry.set_enabled(original_enabled) + await dev.update() + assert babycry.enabled is original_enabled + assert bcde_feat.value is original_enabled + + await bcde_feat.set_value(not original_enabled) + await dev.update() + assert babycry.enabled is not original_enabled + assert bcde_feat.value is not original_enabled + + finally: + await babycry.set_enabled(original_enabled) diff --git a/tests/smartcam/modules/test_camera.py b/tests/smartcam/modules/test_camera.py new file mode 100644 index 00000000..ebc08101 --- /dev/null +++ b/tests/smartcam/modules/test_camera.py @@ -0,0 +1,94 @@ +"""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 camera_smartcam, device_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 + + +@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 + + +@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_motiondetection.py b/tests/smartcam/modules/test_motiondetection.py new file mode 100644 index 00000000..c4ff9807 --- /dev/null +++ b/tests/smartcam/modules/test_motiondetection.py @@ -0,0 +1,43 @@ +"""Tests for smartcam motion detection module.""" + +from __future__ import annotations + +from kasa import Device +from kasa.smartcam.smartcammodule import SmartCamModule + +from ...device_fixtures import parametrize + +motiondetection = parametrize( + "has motion detection", component_filter="detection", protocol_filter={"SMARTCAM"} +) + + +@motiondetection +async def test_motiondetection(dev: Device): + """Test device motion detection.""" + motion = dev.modules.get(SmartCamModule.SmartCamMotionDetection) + assert motion + + mde_feat = dev.features.get("motion_detection") + assert mde_feat + + original_enabled = motion.enabled + + try: + await motion.set_enabled(not original_enabled) + await dev.update() + assert motion.enabled is not original_enabled + assert mde_feat.value is not original_enabled + + await motion.set_enabled(original_enabled) + await dev.update() + assert motion.enabled is original_enabled + assert mde_feat.value is original_enabled + + await mde_feat.set_value(not original_enabled) + await dev.update() + assert motion.enabled is not original_enabled + assert mde_feat.value is not original_enabled + + finally: + await motion.set_enabled(original_enabled) diff --git a/tests/smartcam/modules/test_persondetection.py b/tests/smartcam/modules/test_persondetection.py new file mode 100644 index 00000000..34137587 --- /dev/null +++ b/tests/smartcam/modules/test_persondetection.py @@ -0,0 +1,45 @@ +"""Tests for smartcam person detection module.""" + +from __future__ import annotations + +from kasa import Device +from kasa.smartcam.smartcammodule import SmartCamModule + +from ...device_fixtures import parametrize + +persondetection = parametrize( + "has person detection", + component_filter="personDetection", + protocol_filter={"SMARTCAM"}, +) + + +@persondetection +async def test_persondetection(dev: Device): + """Test device person detection.""" + person = dev.modules.get(SmartCamModule.SmartCamPersonDetection) + assert person + + pde_feat = dev.features.get("person_detection") + assert pde_feat + + original_enabled = person.enabled + + try: + await person.set_enabled(not original_enabled) + await dev.update() + assert person.enabled is not original_enabled + assert pde_feat.value is not original_enabled + + await person.set_enabled(original_enabled) + await dev.update() + assert person.enabled is original_enabled + assert pde_feat.value is original_enabled + + await pde_feat.set_value(not original_enabled) + await dev.update() + assert person.enabled is not original_enabled + assert pde_feat.value is not original_enabled + + finally: + await person.set_enabled(original_enabled) diff --git a/tests/smartcam/modules/test_tamperdetection.py b/tests/smartcam/modules/test_tamperdetection.py new file mode 100644 index 00000000..ab2f851d --- /dev/null +++ b/tests/smartcam/modules/test_tamperdetection.py @@ -0,0 +1,45 @@ +"""Tests for smartcam tamper detection module.""" + +from __future__ import annotations + +from kasa import Device +from kasa.smartcam.smartcammodule import SmartCamModule + +from ...device_fixtures import parametrize + +tamperdetection = parametrize( + "has tamper detection", + component_filter="tamperDetection", + protocol_filter={"SMARTCAM"}, +) + + +@tamperdetection +async def test_tamperdetection(dev: Device): + """Test device tamper detection.""" + tamper = dev.modules.get(SmartCamModule.SmartCamTamperDetection) + assert tamper + + tde_feat = dev.features.get("tamper_detection") + assert tde_feat + + original_enabled = tamper.enabled + + try: + await tamper.set_enabled(not original_enabled) + await dev.update() + assert tamper.enabled is not original_enabled + assert tde_feat.value is not original_enabled + + await tamper.set_enabled(original_enabled) + await dev.update() + assert tamper.enabled is original_enabled + assert tde_feat.value is original_enabled + + await tde_feat.set_value(not original_enabled) + await dev.update() + assert tamper.enabled is not original_enabled + assert tde_feat.value is not original_enabled + + finally: + await tamper.set_enabled(original_enabled) diff --git a/tests/smartcam/test_smartcamdevice.py b/tests/smartcam/test_smartcamdevice.py new file mode 100644 index 00000000..3355d2f0 --- /dev/null +++ b/tests/smartcam/test_smartcamdevice.py @@ -0,0 +1,71 @@ +"""Tests for smart camera devices.""" + +from __future__ import annotations + +from datetime import UTC, datetime + +import pytest +from freezegun.api import FrozenDateTimeFactory + +from kasa import Device, DeviceType, Module + +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): + test_alias = "TEST1234" + original = dev.alias + + assert isinstance(original, str) + await dev.set_alias(test_alias) + await dev.update() + assert dev.alias == test_alias + + await dev.set_alias(original) + await dev.update() + assert dev.alias == original + + +@hub_smartcam +async def test_hub(dev): + assert dev.children + for child in dev.children: + assert "Cloud" in child.modules + assert child.modules["Cloud"].data + assert child.alias + await child.update() + assert "Time" not in child.modules + assert child.time + + +@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 diff --git a/tests/test_bulb.py b/tests/test_bulb.py new file mode 100644 index 00000000..f7a77a8d --- /dev/null +++ b/tests/test_bulb.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +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} diff --git a/tests/test_childdevice.py b/tests/test_childdevice.py new file mode 100644 index 00000000..8bcc05db --- /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 00000000..3621ef20 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,1376 @@ +import json +import os +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_smart, + get_device_for_fixture_protocol, + handle_turn_on, + new_discovery, + 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] + + +@pytest.fixture +def runner(): + """Runner fixture that unsets the KASA_ environment variables for tests.""" + KASA_VARS = {k: None for k, v in os.environ.items() if k.startswith("KASA_")} + runner = CliRunner(env=KASA_VARS) + + return runner + + +async def test_help(runner): + """Test that all the lazy modules are correctly names.""" + res = await runner.invoke(cli, ["--help"]) + 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 + + await dev.set_alias(old_alias) + + +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"] + 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) + + +@device_smart +async def test_wifi_join(dev, mocker, runner): + update = mocker.patch.object(dev, "update") + res = await runner.invoke( + wifi, + ["join", "FOOBAR", "--keytype", "wpa_psk", "--password", "foobar"], + 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.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_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+ failed", + res.output.replace("\n", ""), + ) + assert re.search( + r"Attempt to connect to 127\.0\.0\.1 with \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 00000000..cba1ef87 --- /dev/null +++ b/tests/test_common_modules.py @@ -0,0 +1,401 @@ +from datetime import datetime +from zoneinfo import ZoneInfo + +import pytest +from pytest_mock import MockerFixture + +from kasa import Device, LightState, Module, ThermostatState + +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"}, +) + + +@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 diff --git a/tests/test_device.py b/tests/test_device.py new file mode 100644 index 00000000..7547182b --- /dev/null +++ b/tests/test_device.py @@ -0,0 +1,403 @@ +"""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 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) + 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 + + +@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): + parent = SmartDevice(host, config=config) + dev = klass( + parent, + {"dummy": "info", "device_id": "dummy"}, + { + "component_list": [{"id": "device", "ver_code": 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): + parent = SmartDevice(host, config=config) + dev = klass( + parent, + {"dummy": "info", "device_id": "dummy"}, + { + "component_list": [{"id": "device", "ver_code": 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.Camera, + } + type_ = CLASS_TO_DEFAULT_TYPE[klass] + child_repr = ">" + not_child_repr = f"<{type_} at 127.0.0.2 - update() needed>" + expected_repr = child_repr if klass is SmartChildDevice else not_child_repr + 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 00000000..66e24324 --- /dev/null +++ b/tests/test_device_factory.py @@ -0,0 +1,290 @@ +"""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 = DeviceConnectionParameters.from_values( + dr.device_type, + dr.mgt_encrypt_schm.encrypt_type, + dr.mgt_encrypt_schm.lv, + dr.mgt_encrypt_schm.is_support_https, + ) + 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 = 80 if "result" in discovery_data else 9999 + + 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.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", + ), + ], +) +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/kasa/tests/test_device_type.py b/tests/test_device_type.py similarity index 95% rename from kasa/tests/test_device_type.py rename to tests/test_device_type.py index da1707dc..099f0862 100644 --- a/kasa/tests/test_device_type.py +++ b/tests/test_device_type.py @@ -1,4 +1,4 @@ -from kasa.smartdevice import DeviceType +from kasa.device_type import DeviceType async def test_device_type_from_value(): diff --git a/tests/test_deviceconfig.py b/tests/test_deviceconfig.py new file mode 100644 index 00000000..aebdd3a6 --- /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 00000000..8bdd5746 --- /dev/null +++ b/tests/test_devtools.py @@ -0,0 +1,105 @@ +"""Module for dump_devinfo tests.""" + +import pytest + +from devtools.dump_devinfo import get_legacy_fixture, get_smart_fixtures +from kasa.iot import IotDevice +from kasa.protocols import IotProtocol +from kasa.smart import SmartDevice +from kasa.smartcam import SmartCamDevice + +from .conftest import ( + FixtureInfo, + get_device_for_fixture, + 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 + + +@smartcam_fixtures +async def test_smartcam_fixtures(fixture_info: FixtureInfo): + """Test that smartcam fixtures are created the same.""" + dev = await get_device_for_fixture(fixture_info, verbatim=True) + assert isinstance(dev, SmartCamDevice) + if dev.children: + pytest.skip("Test not currently implemented for devices with children.") + fixtures = await get_smart_fixtures( + dev.protocol, + discovery_info=fixture_info.data.get("discovery_result"), + batch_size=5, + ) + fixture_result = fixtures[0] + + assert fixture_info.data == fixture_result.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 00000000..59a337d2 --- /dev/null +++ b/tests/test_discovery.py @@ -0,0 +1,733 @@ +# 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 + + device_class = Discover._get_device_class(discovery_mock.discovery_data) + # 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 + # Make sure discovery does not call update() + assert update_mock.call_count == 0 + if discovery_mock.default_port == 80: + assert x.alias is None + + ct = DeviceConnectionParameters.from_values( + discovery_mock.device_type, + discovery_mock.encrypt_type, + discovery_mock.login_version, + ) + uses_http = discovery_mock.default_port == 80 + config = DeviceConfig( + host=host, + port_override=custom_port, + connection_type=ct, + uses_http=uses_http, + credentials=Credentials(), + ) + assert x.config == config + + +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 + proto = _DiscoverProtocol(discovery_timeout=discovery_timeout) + assert proto.discovery_packets == 3 + assert proto.target_1 == ("255.255.255.255", 9999) + transport = mocker.patch.object(proto, "transport") + await proto.do_discover() + assert transport.sendto.call_count == proto.discovery_packets * 2 + + +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 == 80) + + if discovery_mock.default_port == 80: + 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 == 80) + + if discovery_mock.default_port == 80: + 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, + discovery_mock.login_version, + discovery_mock.https, + ) + 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 + + async def _query(self, *args, **kwargs): + if ( + self.__class__ is protocol_class + and self._transport.__class__ is transport_class + ): + return discovery_mock.query_data + raise KasaException() + + async def _update(self, *args, **kwargs): + if ( + self.protocol.__class__ is protocol_class + and self.protocol._transport.__class__ is transport_class + ): + return + raise KasaException() + + 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 00000000..46cdd116 --- /dev/null +++ b/tests/test_feature.py @@ -0,0 +1,213 @@ +import logging +from unittest.mock import AsyncMock, patch + +import pytest +from pytest_mock import MockerFixture + +from kasa import Device, Feature, KasaException + +_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() + 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(ValueError, match="Unexpected value for dummy_feature: invalid"): # noqa: PT012 + 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 + + expecting_call = feat.id not in internal_setters + + 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: + with patch.object(feat.device.protocol, "query") as query: + 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 00000000..906b39ed --- /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 00000000..25be910b --- /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 00000000..b6513476 --- /dev/null +++ b/tests/test_readme_examples.py @@ -0,0 +1,179 @@ +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"] + + +@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 + } + 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" + ) + return patch_discovery(fixture_infos, mocker) diff --git a/kasa/tests/test_strip.py b/tests/test_strip.py similarity index 74% rename from kasa/tests/test_strip.py rename to tests/test_strip.py index 451b7e34..73d10bb7 100644 --- a/kasa/tests/test_strip.py +++ b/tests/test_strip.py @@ -2,9 +2,10 @@ from datetime import datetime import pytest -from kasa import SmartDeviceException, SmartStrip +from kasa import Device, KasaException, Module +from kasa.iot import IotStrip -from .conftest import handle_turn_on, strip, turn_on +from .conftest import handle_turn_on, strip, strip_iot, turn_on @strip @@ -68,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] # 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 @@ -130,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 00000000..e69de29b diff --git a/tests/transports/test_aestransport.py b/tests/transports/test_aestransport.py new file mode 100644 index 00000000..64bc8d4e --- /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/kasa/tests/test_klapprotocol.py b/tests/transports/test_klaptransport.py similarity index 53% rename from kasa/tests/test_klapprotocol.py rename to tests/transports/test_klaptransport.py index 5108fef0..26d9f57a 100644 --- a/kasa/tests/test_klapprotocol.py +++ b/tests/transports/test_klaptransport.py @@ -1,57 +1,104 @@ -import errno import json import logging +import re import secrets -import struct -import sys import time from contextlib import nullcontext as does_not_raise -import httpx +import aiohttp import pytest +from yarl import URL -from ..aestransport import AesTransport -from ..credentials import Credentials -from ..deviceconfig import DeviceConfig -from ..exceptions import AuthenticationException, SmartDeviceException -from ..iotprotocol import IotProtocol -from ..klaptransport import ( +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, ) -from ..smartprotocol import SmartProtocol 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_code, content: bytes): - self.status_code = status_code + 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", + ("error", "retry_expectation"), [ - (Exception("dummy exception"), True), - (SmartDeviceException("dummy exception"), False), - (httpx.ConnectError("dummy exception"), True), + (Exception("dummy exception"), False), + (aiohttp.ServerTimeoutError("dummy exception"), True), + (aiohttp.ServerDisconnectedError("dummy exception"), True), + (aiohttp.ClientOSError("dummy exception"), True), ], - ids=("Exception", "SmartDeviceException", "httpx.ConnectError"), + 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( +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(httpx.AsyncClient, "post", side_effect=error) + conn = mocker.patch.object(aiohttp.ClientSession, "post", side_effect=error) config = DeviceConfig(host) - with pytest.raises(SmartDeviceException): + 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 ) @@ -67,12 +114,13 @@ async def test_protocol_no_retry_on_connection_error( ): host = "127.0.0.1" conn = mocker.patch.object( - httpx.AsyncClient, + aiohttp.ClientSession, "post", - side_effect=AuthenticationException("foo"), + side_effect=AuthenticationError("foo"), ) + mocker.patch.object(protocol_class, "BACKOFF_SECONDS_AFTER_TIMEOUT", 0) config = DeviceConfig(host) - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): await protocol_class(transport=transport_class(config=config)).query( DUMMY_QUERY, retry_count=5 ) @@ -87,12 +135,13 @@ async def test_protocol_retry_recoverable_error( ): host = "127.0.0.1" conn = mocker.patch.object( - httpx.AsyncClient, + aiohttp.ClientSession, "post", - side_effect=httpx.CloseError("foo"), + side_effect=aiohttp.ClientOSError("foo"), ) + config = DeviceConfig(host) - with pytest.raises(SmartDeviceException): + with pytest.raises(KasaException): await protocol_class(transport=transport_class(config=config)).query( DUMMY_QUERY, retry_count=5 ) @@ -112,7 +161,7 @@ async def test_protocol_reconnect(mocker, retry_count, protocol_class, transport nonlocal remaining remaining -= 1 if remaining: - raise Exception("Simulated post failure") + raise _ConnectionError("Simulated connection failure") return mock_response @@ -135,6 +184,7 @@ async def test_protocol_reconnect(mocker, retry_count, protocol_class, transport @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) @@ -155,7 +205,7 @@ async def test_protocol_logging(mocker, caplog, log_level): protocol._transport._handshake_done = True protocol._transport._session_expire_at = time.time() + 86400 protocol._transport._encryption_session = encryption_session - mocker.patch.object(KlapTransport, "client_post", side_effect=_return_encrypted) + mocker.patch.object(HttpClient, "post", side_effect=_return_encrypted) response = await protocol.query({}) assert response == {"great": "success"} @@ -191,27 +241,89 @@ def test_encrypt_unicode(): 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", + ("device_credentials", "expectation"), [ (Credentials("foo", "bar"), does_not_raise()), (Credentials(), does_not_raise()), ( - Credentials( - KlapTransport.KASA_SETUP_EMAIL, - KlapTransport.KASA_SETUP_PASSWORD, - ), + get_default_credentials(DEFAULT_CREDENTIALS["KASA"]), does_not_raise(), ), ( Credentials("shouldfail", "shouldfail"), - pytest.raises(AuthenticationException), + pytest.raises(AuthenticationError), ), ], ids=("client", "blank", "kasa_setup", "shouldfail"), ) @pytest.mark.parametrize( - "transport_class, seed_auth_hash_calc", + ("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"), @@ -235,7 +347,7 @@ async def test_handshake1( device_auth_hash = transport_class.generate_auth_hash(device_credentials) mocker.patch.object( - httpx.AsyncClient, "post", side_effect=_return_handshake1_response + aiohttp.ClientSession, "post", side_effect=_return_handshake1_response ) config = DeviceConfig("127.0.0.1", credentials=client_credentials) @@ -255,7 +367,7 @@ async def test_handshake1( @pytest.mark.parametrize( - "transport_class, seed_auth_hash_calc1, seed_auth_hash_calc2", + ("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" @@ -271,59 +383,66 @@ async def test_handshake1( async def test_handshake( mocker, transport_class, seed_auth_hash_calc1, seed_auth_hash_calc2 ): - async def _return_handshake_response(url, params=None, data=None, *_, **__): + client_seed = None + server_seed = secrets.token_bytes(16) + client_credentials = Credentials("foo", "bar") + device_auth_hash = transport_class.generate_auth_hash(client_credentials) + + async def _return_handshake_response(url: URL, params=None, data=None, *_, **__): nonlocal client_seed, server_seed, device_auth_hash - if url == "http://127.0.0.1/app/handshake1": + 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 == "http://127.0.0.1/app/handshake2": + 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"") - client_seed = None - server_seed = secrets.token_bytes(16) - client_credentials = Credentials("foo", "bar") - device_auth_hash = transport_class.generate_auth_hash(client_credentials) - mocker.patch.object( - httpx.AsyncClient, "post", side_effect=_return_handshake_response + 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 = httpx.AsyncClient() + 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(AuthenticationException): + with pytest.raises(KasaException): await protocol._transport.perform_handshake() assert protocol._transport._handshake_done is False await protocol.close() async def test_query(mocker): - async def _return_response(url, params=None, data=None, *_, **__): + 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 == "http://127.0.0.1/app/handshake1": + 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 == "http://127.0.0.1/app/handshake2": + elif url == URL("http://127.0.0.1:80/app/handshake2"): return _mock_response(200, b"") - elif url == "http://127.0.0.1/app/request": + 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, @@ -335,14 +454,7 @@ async def test_query(mocker): seq = seq return _mock_response(200, encrypted) - 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) - - mocker.patch.object(httpx.AsyncClient, "post", side_effect=_return_response) + 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)) @@ -356,41 +468,98 @@ async def test_query(mocker): @pytest.mark.parametrize( - "response_status, expectation", + ("response_status", "credentials_match", "expectation"), [ - ((403, 403, 403), pytest.raises(AuthenticationException)), - ((200, 403, 403), pytest.raises(AuthenticationException)), - ((200, 200, 403), pytest.raises(AuthenticationException)), - ((200, 200, 400), pytest.raises(SmartDeviceException)), + 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", + ), ], - ids=("handshake1", "handshake2", "request", "non_auth_error"), ) -async def test_authentication_failures(mocker, response_status, expectation): - async def _return_response(url, params=None, data=None, *_, **__): - nonlocal client_seed, server_seed, device_auth_hash, response_status - - if url == "http://127.0.0.1/app/handshake1": - client_seed = data - client_seed_auth_hash = _sha256(data + device_auth_hash) - - return _mock_response( - response_status[0], server_seed + client_seed_auth_hash - ) - elif url == "http://127.0.0.1/app/handshake2": - return _mock_response(response_status[1], b"") - elif url == "http://127.0.0.1/app/request": - return _mock_response(response_status[2], None) - +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_auth_hash = KlapTransport.generate_auth_hash(client_credentials) + device_credentials = ( + client_credentials if credentials_match else Credentials("bar", "foo") + ) + device_auth_hash = KlapTransport.generate_auth_hash(device_credentials) - mocker.patch.object(httpx.AsyncClient, "post", side_effect=_return_response) + 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 00000000..1ac8dba5 --- /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 00000000..6816fa35 --- /dev/null +++ b/tests/transports/test_sslaestransport.py @@ -0,0 +1,380 @@ +from __future__ import annotations + +import logging +import secrets +from contextlib import nullcontext as does_not_raise +from json import dumps as json_dumps +from json import loads as json_loads +from typing import Any + +import aiohttp +import pytest +from yarl import URL + +from kasa.credentials import DEFAULT_CREDENTIALS, Credentials, get_default_credentials +from kasa.deviceconfig import DeviceConfig +from kasa.exceptions import ( + AuthenticationError, + KasaException, + SmartErrorCode, +) +from kasa.httpclient import HttpClient +from kasa.transports.aestransport import AesEncyptionSession +from kasa.transports.sslaestransport import ( + SslAesTransport, + TransportState, + _sha256_hash, +) + +# Transport tests are not designed for real devices +pytestmark = [pytest.mark.requires_dummy] + +MOCK_ADMIN_USER = get_default_credentials(DEFAULT_CREDENTIALS["TAPOCAMERA"]).username +MOCK_PWD = "correct_pwd" # noqa: S105 +MOCK_USER = "mock@example.com" +MOCK_STOCK = "abcdefghijklmnopqrstuvwxyz1234)(" + + +@pytest.mark.parametrize( + ( + "status_code", + "username", + "password", + "wants_default_user", + "digest_password_fail", + "expectation", + ), + [ + pytest.param( + 200, MOCK_USER, MOCK_PWD, False, False, does_not_raise(), id="success" + ), + pytest.param( + 200, + MOCK_USER, + MOCK_PWD, + True, + False, + does_not_raise(), + id="success-default", + ), + pytest.param( + 400, + MOCK_USER, + MOCK_PWD, + False, + False, + pytest.raises(KasaException), + id="400 error", + ), + pytest.param( + 200, + "foobar", + MOCK_PWD, + False, + False, + pytest.raises(AuthenticationError), + id="bad-username", + ), + pytest.param( + 200, + MOCK_USER, + "barfoo", + False, + False, + pytest.raises(AuthenticationError), + id="bad-password", + ), + pytest.param( + 200, + MOCK_USER, + MOCK_PWD, + False, + True, + pytest.raises(AuthenticationError), + id="bad-password-digest", + ), + ], +) +async def test_handshake( + mocker, + status_code, + username, + password, + wants_default_user, + digest_password_fail, + expectation, +): + host = "127.0.0.1" + mock_ssl_aes_device = MockSslAesDevice( + host, + status_code=status_code, + want_default_username=wants_default_user, + digest_password_fail=digest_password_fail, + ) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + transport = SslAesTransport( + config=DeviceConfig(host, credentials=Credentials(username, password)) + ) + + assert transport._encryption_session is None + assert transport._state is TransportState.HANDSHAKE_REQUIRED + with expectation: + await transport.perform_handshake() + assert transport._encryption_session is not None + assert transport._state is TransportState.ESTABLISHED + + +@pytest.mark.parametrize( + ("wants_default_user"), + [pytest.param(False, id="username"), pytest.param(True, id="default")], +) +async def test_credentials_hash(mocker, wants_default_user): + host = "127.0.0.1" + mock_ssl_aes_device = MockSslAesDevice( + host, want_default_username=wants_default_user + ) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + creds = Credentials(MOCK_USER, MOCK_PWD) + creds_hash = SslAesTransport._create_b64_credentials(creds) + + # Test with credentials input + transport = SslAesTransport(config=DeviceConfig(host, credentials=creds)) + assert transport.credentials_hash == creds_hash + await transport.perform_handshake() + assert transport.credentials_hash == creds_hash + + # Test with credentials_hash input + transport = SslAesTransport(config=DeviceConfig(host, credentials_hash=creds_hash)) + mock_ssl_aes_device.handshake1_complete = False + assert transport.credentials_hash == creds_hash + await transport.perform_handshake() + assert transport.credentials_hash == creds_hash + + +async def test_send(mocker): + host = "127.0.0.1" + mock_ssl_aes_device = MockSslAesDevice(host, want_default_username=False) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + transport = SslAesTransport( + config=DeviceConfig(host, credentials=Credentials(MOCK_USER, MOCK_PWD)) + ) + request = { + "method": "getDeviceInfo", + "params": None, + } + + res = await transport.send(json_dumps(request)) + assert "result" in res + + +@pytest.mark.xdist_group(name="caplog") +async def test_unencrypted_response(mocker, caplog): + host = "127.0.0.1" + mock_ssl_aes_device = MockSslAesDevice(host, do_not_encrypt_response=True) + mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_ssl_aes_device.post + ) + + transport = SslAesTransport( + config=DeviceConfig(host, credentials=Credentials(MOCK_USER, MOCK_PWD)) + ) + + request = { + "method": "getDeviceInfo", + "params": None, + } + caplog.set_level(logging.DEBUG) + res = await transport.send(json_dumps(request)) + assert "result" in res + assert ( + "Received unencrypted response over secure passthrough from 127.0.0.1" + in caplog.text + ) + + +async def test_port_override(): + """Test that port override sets the app_url.""" + host = "127.0.0.1" + port_override = 12345 + config = DeviceConfig( + host, credentials=Credentials("foo", "bar"), port_override=port_override + ) + transport = SslAesTransport(config=config) + + assert str(transport._app_url) == f"https://127.0.0.1:{port_override}" + + +class MockSslAesDevice: + BAD_USER_RESP = { + "error_code": SmartErrorCode.SESSION_EXPIRED.value, + "result": { + "data": { + "code": -60502, + } + }, + } + + BAD_PWD_RESP = { + "error_code": SmartErrorCode.INVALID_NONCE.value, + "result": { + "data": { + "code": SmartErrorCode.SESSION_EXPIRED.value, + "encrypt_type": ["3"], + "key": "Someb64keyWithUnknownPurpose", + "nonce": "1234567890ABCDEF", # Whatever the original nonce was + "device_confirm": "", + } + }, + } + + class _mock_response: + def __init__(self, status, request: dict): + self.status = status + self._json = request + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_t, exc_v, exc_tb): + pass + + async def read(self): + if isinstance(self._json, dict): + return json_dumps(self._json).encode() + return self._json + + def __init__( + self, + host, + *, + status_code=200, + want_default_username: bool = False, + do_not_encrypt_response=False, + send_response=None, + sequential_request_delay=0, + send_error_code=0, + secure_passthrough_error_code=0, + digest_password_fail=False, + ): + self.host = host + self.http_client = HttpClient(DeviceConfig(self.host)) + self.encryption_session: AesEncyptionSession | None = None + self.server_nonce = secrets.token_bytes(8).hex().upper() + self.handshake1_complete = False + + # test behaviour attributes + self.status_code = status_code + self.send_error_code = send_error_code + self.secure_passthrough_error_code = secure_passthrough_error_code + self.do_not_encrypt_response = do_not_encrypt_response + self.want_default_username = want_default_username + self.digest_password_fail = digest_password_fail + + async def post(self, url: URL, params=None, json=None, data=None, *_, **__): + if data: + json = json_loads(data) + res = await self._post(url, json) + return res + + async def _post(self, url: URL, json: dict[str, Any]): + method = json["method"] + + if method == "login" and not self.handshake1_complete: + return await self._return_handshake1_response(url, json) + + if method == "login" and self.handshake1_complete: + return await self._return_handshake2_response(url, json) + elif method == "securePassthrough": + assert url == URL(f"https://{self.host}/stok={MOCK_STOCK}/ds") + return await self._return_secure_passthrough_response(url, json) + else: + assert url == URL(f"https://{self.host}/stok={MOCK_STOCK}/ds") + return await self._return_send_response(url, json) + + async def _return_handshake1_response(self, url: URL, request: dict[str, Any]): + request_nonce = request["params"].get("cnonce") + request_username = request["params"].get("username") + + if (self.want_default_username and request_username != MOCK_ADMIN_USER) or ( + not self.want_default_username and request_username != MOCK_USER + ): + return self._mock_response(self.status_code, self.BAD_USER_RESP) + + device_confirm = SslAesTransport.generate_confirm_hash( + request_nonce, self.server_nonce, _sha256_hash(MOCK_PWD.encode()) + ) + self.handshake1_complete = True + resp = { + "error_code": SmartErrorCode.INVALID_NONCE.value, + "result": { + "data": { + "code": SmartErrorCode.INVALID_NONCE.value, + "encrypt_type": ["3"], + "key": "Someb64keyWithUnknownPurpose", + "nonce": self.server_nonce, + "device_confirm": device_confirm, + } + }, + } + return self._mock_response(self.status_code, resp) + + async def _return_handshake2_response(self, url: URL, request: dict[str, Any]): + request_nonce = request["params"].get("cnonce") + request_username = request["params"].get("username") + if (self.want_default_username and request_username != MOCK_ADMIN_USER) or ( + not self.want_default_username and request_username != MOCK_USER + ): + return self._mock_response(self.status_code, self.BAD_USER_RESP) + + request_password = request["params"].get("digest_passwd") + expected_pwd = SslAesTransport.generate_digest_password( + request_nonce, self.server_nonce, _sha256_hash(MOCK_PWD.encode()) + ) + if request_password != expected_pwd or self.digest_password_fail: + return self._mock_response(self.status_code, self.BAD_PWD_RESP) + + lsk = SslAesTransport.generate_encryption_token( + "lsk", request_nonce, self.server_nonce, _sha256_hash(MOCK_PWD.encode()) + ) + ivb = SslAesTransport.generate_encryption_token( + "ivb", request_nonce, self.server_nonce, _sha256_hash(MOCK_PWD.encode()) + ) + self.encryption_session = AesEncyptionSession(lsk, ivb) + resp = { + "error_code": 0, + "result": {"stok": MOCK_STOCK, "user_group": "root", "start_seq": 100}, + } + return self._mock_response(self.status_code, resp) + + async def _return_secure_passthrough_response(self, url: URL, json: dict[str, Any]): + encrypted_request = json["params"]["request"] + assert self.encryption_session + decrypted_request = self.encryption_session.decrypt(encrypted_request.encode()) + decrypted_request_dict = json_loads(decrypted_request) + decrypted_response = await self._post(url, decrypted_request_dict) + async with decrypted_response: + decrypted_response_data = await decrypted_response.read() + + encrypted_response = self.encryption_session.encrypt(decrypted_response_data) + response = ( + decrypted_response_data + if self.do_not_encrypt_response + else encrypted_response + ) + result = { + "result": {"response": response.decode()}, + "error_code": self.secure_passthrough_error_code, + } + return self._mock_response(self.status_code, result) + + async def _return_send_response(self, url: URL, json: dict[str, Any]): + result = {"result": {"method": None}, "error_code": self.send_error_code} + return self._mock_response(self.status_code, result) diff --git a/tests/transports/test_ssltransport.py b/tests/transports/test_ssltransport.py new file mode 100644 index 00000000..37b79725 --- /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 5843142c..00000000 --- a/tox.ini +++ /dev/null @@ -1,37 +0,0 @@ -[tox] -envlist=py37,py38,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: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 00000000..c6802330 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1567 @@ +version = 1 +requires-python = ">=3.11, <4.0" + +[[package]] +name = "aiohappyeyeballs" +version = "2.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/69/2f6d5a019bd02e920a3417689a89887b39ad1e350b562f9955693d900c40/aiohappyeyeballs-2.4.3.tar.gz", hash = "sha256:75cf88a15106a5002a8eb1dab212525c00d1f4c0fa96e551c9fbe6f09a621586", size = 21809 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/d8/120cd0fe3e8530df0539e71ba9683eade12cae103dd7543e50d15f737917/aiohappyeyeballs-2.4.3-py3-none-any.whl", hash = "sha256:8a7a83727b2756f394ab2895ea0765a0a8c475e3c71e98d43d76f22b4b435572", size = 14742 }, +] + +[[package]] +name = "aiohttp" +version = "3.11.7" +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/4b/cb/f9bb10e0cf6f01730b27d370b10cc15822bea4395acd687abc8cc5fed3ed/aiohttp-3.11.7.tar.gz", hash = "sha256:01a8aca4af3da85cea5c90141d23f4b0eee3cbecfd33b029a45a80f28c66c668", size = 7666482 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/7f/272fa1adf68fe2fbebfe686a67b50cfb40d86dfe47d0441aff6f0b7c4c0e/aiohttp-3.11.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cea52d11e02123f125f9055dfe0ccf1c3857225fb879e4a944fae12989e2aef2", size = 706820 }, + { url = "https://files.pythonhosted.org/packages/79/3c/6d612ef77cdba75364393f04c5c577481e3b5123a774eea447ada1ddd14f/aiohttp-3.11.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3ce18f703b7298e7f7633efd6a90138d99a3f9a656cb52c1201e76cb5d79cf08", size = 466654 }, + { url = "https://files.pythonhosted.org/packages/4f/b8/1052667d4800cd49bb4f869f1ed42f5e9d5acd4676275e64ccc244c9c040/aiohttp-3.11.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:670847ee6aeb3a569cd7cdfbe0c3bec1d44828bbfbe78c5d305f7f804870ef9e", size = 454041 }, + { url = "https://files.pythonhosted.org/packages/9f/07/80fa7302314a6ee1c9278550e9d95b77a4c895999bfbc5364ed0ee28dc7c/aiohttp-3.11.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4dda726f89bfa5c465ba45b76515135a3ece0088dfa2da49b8bb278f3bdeea12", size = 1684778 }, + { url = "https://files.pythonhosted.org/packages/2e/30/a71eb45197ad6bb6af87dfb39be8b56417d24d916047d35ef3f164af87f4/aiohttp-3.11.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c25b74a811dba37c7ea6a14d99eb9402d89c8d739d50748a75f3cf994cf19c43", size = 1740992 }, + { url = "https://files.pythonhosted.org/packages/22/74/0f9394429f3c4197129333a150a85cb2a642df30097a39dd41257f0b3bdc/aiohttp-3.11.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5522ee72f95661e79db691310290c4618b86dff2d9b90baedf343fd7a08bf79", size = 1781816 }, + { url = "https://files.pythonhosted.org/packages/7f/1a/1e256b39179c98d16d53ac62f64bfcfe7c5b2c1e68b83cddd4165854524f/aiohttp-3.11.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1fbf41a6bbc319a7816ae0f0177c265b62f2a59ad301a0e49b395746eb2a9884", size = 1676692 }, + { url = "https://files.pythonhosted.org/packages/9b/37/f19d2e00efcabb9183b16bd91244de1d9c4ff7bf0fb5b8302e29a78f3286/aiohttp-3.11.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:59ee1925b5a5efdf6c4e7be51deee93984d0ac14a6897bd521b498b9916f1544", size = 1619523 }, + { url = "https://files.pythonhosted.org/packages/ae/3c/af50cf5e06b98783fd776f17077f7b7e755d461114af5d6744dc037fc3b0/aiohttp-3.11.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:24054fce8c6d6f33a3e35d1c603ef1b91bbcba73e3f04a22b4f2f27dac59b347", size = 1644084 }, + { url = "https://files.pythonhosted.org/packages/c0/a6/4e0233b085cbf2b6de573515c1eddde82f1c1f17e69347e32a5a5f2617ff/aiohttp-3.11.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:351849aca2c6f814575c1a485c01c17a4240413f960df1bf9f5deb0003c61a53", size = 1648332 }, + { url = "https://files.pythonhosted.org/packages/06/20/7062e76e7817318c421c0f9d7b650fb81aaecf6d2f3a9833805b45ec2ea8/aiohttp-3.11.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:12724f3a211fa243570e601f65a8831372caf1a149d2f1859f68479f07efec3d", size = 1730912 }, + { url = "https://files.pythonhosted.org/packages/6c/1c/ff6ae4b1789894e6faf8a4e260cd3861cad618dc80ad15326789a7765750/aiohttp-3.11.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:7ea4490360b605804bea8173d2d086b6c379d6bb22ac434de605a9cbce006e7d", size = 1752619 }, + { url = "https://files.pythonhosted.org/packages/33/58/ddd5cba5ca245c00b04e9d28a7988b0f0eda02de494f8e62ecd2780655c2/aiohttp-3.11.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e0bf378db07df0a713a1e32381a1b277e62ad106d0dbe17b5479e76ec706d720", size = 1692801 }, + { url = "https://files.pythonhosted.org/packages/b2/fc/32d5e2070b43d3722b7ea65ddc6b03ffa39bcc4b5ab6395a825cde0872ad/aiohttp-3.11.7-cp311-cp311-win32.whl", hash = "sha256:cd8d62cab363dfe713067027a5adb4907515861f1e4ce63e7be810b83668b847", size = 414899 }, + { url = "https://files.pythonhosted.org/packages/ec/7e/50324c6d3df4540f5963def810b9927f220c99864065849a1dfcae77a6ce/aiohttp-3.11.7-cp311-cp311-win_amd64.whl", hash = "sha256:bf0e6cce113596377cadda4e3ac5fb89f095bd492226e46d91b4baef1dd16f60", size = 440938 }, + { url = "https://files.pythonhosted.org/packages/bf/1e/2e96b2526c590dcb99db0b94ac4f9b927ecc07f94735a8a941dee143d48b/aiohttp-3.11.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4bb7493c3e3a36d3012b8564bd0e2783259ddd7ef3a81a74f0dbfa000fce48b7", size = 702326 }, + { url = "https://files.pythonhosted.org/packages/b5/ce/b5d7f3e68849f1f5e0b85af4ac9080b9d3c0a600857140024603653c2209/aiohttp-3.11.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e143b0ef9cb1a2b4f74f56d4fbe50caa7c2bb93390aff52f9398d21d89bc73ea", size = 461944 }, + { url = "https://files.pythonhosted.org/packages/28/fa/f4d98db1b7f8f0c3f74bdbd6d0d98cfc89984205cd33f1b8ee3f588ee5ad/aiohttp-3.11.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f7c58a240260822dc07f6ae32a0293dd5bccd618bb2d0f36d51c5dbd526f89c0", size = 454348 }, + { url = "https://files.pythonhosted.org/packages/04/f0/c238dda5dc9a3d12b76636e2cf0ea475890ac3a1c7e4ff0fd6c3cea2fc2d/aiohttp-3.11.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8d20cfe63a1c135d26bde8c1d0ea46fd1200884afbc523466d2f1cf517d1fe33", size = 1678795 }, + { url = "https://files.pythonhosted.org/packages/79/ee/3a18f792247e6d95dba13aaedc9dc317c3c6e75f4b88c2dd4b960d20ad2f/aiohttp-3.11.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12e4d45847a174f77b2b9919719203769f220058f642b08504cf8b1cf185dacf", size = 1734411 }, + { url = "https://files.pythonhosted.org/packages/f5/79/3eb84243087a9a32cae821622c935107b4b55a5b21b76772e8e6c41092e9/aiohttp-3.11.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cf4efa2d01f697a7dbd0509891a286a4af0d86902fc594e20e3b1712c28c0106", size = 1788959 }, + { url = "https://files.pythonhosted.org/packages/91/93/ad77782c5edfa17aafc070bef978fbfb8459b2f150595ffb01b559c136f9/aiohttp-3.11.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ee6a4cdcbf54b8083dc9723cdf5f41f722c00db40ccf9ec2616e27869151129", size = 1687463 }, + { url = "https://files.pythonhosted.org/packages/ba/48/db35bd21b7877efa0be5f28385d8978c55323c5ce7685712e53f3f6c0bd9/aiohttp-3.11.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c6095aaf852c34f42e1bd0cf0dc32d1e4b48a90bfb5054abdbb9d64b36acadcb", size = 1618374 }, + { url = "https://files.pythonhosted.org/packages/ba/77/30f87db55c79fd145ed5fd15b92f2e820ce81065d41ae437797aaa550e3b/aiohttp-3.11.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1cf03d27885f8c5ebf3993a220cc84fc66375e1e6e812731f51aab2b2748f4a6", size = 1637021 }, + { url = "https://files.pythonhosted.org/packages/af/76/10b188b78ee18d0595af156d6a238bc60f9d8571f0f546027eb7eaf65b25/aiohttp-3.11.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:1a17f6a230f81eb53282503823f59d61dff14fb2a93847bf0399dc8e87817307", size = 1650792 }, + { url = "https://files.pythonhosted.org/packages/fa/33/4411bbb8ad04c47d0f4c7bd53332aaf350e49469cf6b65b132d4becafe27/aiohttp-3.11.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:481f10a1a45c5f4c4a578bbd74cff22eb64460a6549819242a87a80788461fba", size = 1696248 }, + { url = "https://files.pythonhosted.org/packages/fe/2d/6135d0dc1851a33d3faa937b20fef81340bc95e8310536d4c7f1f8ecc026/aiohttp-3.11.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:db37248535d1ae40735d15bdf26ad43be19e3d93ab3f3dad8507eb0f85bb8124", size = 1729188 }, + { url = "https://files.pythonhosted.org/packages/f5/76/a57ceff577ae26fe9a6f31ac799bc638ecf26e4acdf04295290b9929b349/aiohttp-3.11.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9d18a8b44ec8502a7fde91446cd9c9b95ce7c49f1eacc1fb2358b8907d4369fd", size = 1690038 }, + { url = "https://files.pythonhosted.org/packages/4b/81/b20e09003b6989a7f23a721692137a6143420a151063c750ab2a04878e3c/aiohttp-3.11.7-cp312-cp312-win32.whl", hash = "sha256:3d1c9c15d3999107cbb9b2d76ca6172e6710a12fda22434ee8bd3f432b7b17e8", size = 409887 }, + { url = "https://files.pythonhosted.org/packages/b7/0b/607c98bff1d07bb21e0c39e7711108ef9ff4f2a361a3ec1ce8dce93623a5/aiohttp-3.11.7-cp312-cp312-win_amd64.whl", hash = "sha256:018f1b04883a12e77e7fc161934c0f298865d3a484aea536a6a2ca8d909f0ba0", size = 436462 }, + { url = "https://files.pythonhosted.org/packages/7a/53/8d77186c6a33bd087714df18274cdcf6e36fd69a9e841c85b7e81a20b18e/aiohttp-3.11.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:241a6ca732d2766836d62c58c49ca7a93d08251daef0c1e3c850df1d1ca0cbc4", size = 695811 }, + { url = "https://files.pythonhosted.org/packages/62/b6/4c3d107a5406aa6f99f618afea82783f54ce2d9644020f50b9c88f6e823d/aiohttp-3.11.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:aa3705a8d14de39898da0fbad920b2a37b7547c3afd2a18b9b81f0223b7d0f68", size = 458530 }, + { url = "https://files.pythonhosted.org/packages/d9/05/dbf0bd3966be8ebed3beb4007a2d1356d79af4fe7c93e54f984df6385193/aiohttp-3.11.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9acfc7f652b31853eed3b92095b0acf06fd5597eeea42e939bd23a17137679d5", size = 451371 }, + { url = "https://files.pythonhosted.org/packages/19/6a/2198580314617b6cf9c4b813b84df5832b5f8efedcb8a7e8b321a187233c/aiohttp-3.11.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dcefcf2915a2dbdbce37e2fc1622129a1918abfe3d06721ce9f6cdac9b6d2eaa", size = 1662905 }, + { url = "https://files.pythonhosted.org/packages/2b/65/08696fd7503f6a6f9f782bd012bf47f36d4ed179a7d8c95dba4726d5cc67/aiohttp-3.11.7-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c1f6490dd1862af5aae6cfcf2a274bffa9a5b32a8f5acb519a7ecf5a99a88866", size = 1713794 }, + { url = "https://files.pythonhosted.org/packages/c8/a3/b9a72dce6f15e2efbc09fa67c1067c4f3a3bb05661c0ae7b40799cde02b7/aiohttp-3.11.7-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f1ac5462582d6561c1c1708853a9faf612ff4e5ea5e679e99be36143d6eabd8e", size = 1770757 }, + { url = "https://files.pythonhosted.org/packages/78/7e/8fb371b5f8c4c1eaa0d0a50750c0dd68059f86794aeb36919644815486f5/aiohttp-3.11.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1a6309005acc4b2bcc577ba3b9169fea52638709ffacbd071f3503264620da", size = 1673136 }, + { url = "https://files.pythonhosted.org/packages/2f/0f/09685d13d2c7634cb808868ea29c170d4dcde4215a4a90fb86491cd3ae25/aiohttp-3.11.7-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f5b973cce96793725ef63eb449adfb74f99c043c718acb76e0d2a447ae369962", size = 1600370 }, + { url = "https://files.pythonhosted.org/packages/00/2e/18fd38b117f9b3a375166ccb70ed43cf7e3dfe2cc947139acc15feefc5a2/aiohttp-3.11.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ce91a24aac80de6be8512fb1c4838a9881aa713f44f4e91dd7bb3b34061b497d", size = 1613459 }, + { url = "https://files.pythonhosted.org/packages/2c/94/10a82abc680d753be33506be699aaa330152ecc4f316eaf081f996ee56c2/aiohttp-3.11.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:875f7100ce0e74af51d4139495eec4025affa1a605280f23990b6434b81df1bd", size = 1613924 }, + { url = "https://files.pythonhosted.org/packages/e9/58/897c0561f5c522dda6e173192f1e4f10144e1a7126096f17a3f12b7aa168/aiohttp-3.11.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c171fc35d3174bbf4787381716564042a4cbc008824d8195eede3d9b938e29a8", size = 1681164 }, + { url = "https://files.pythonhosted.org/packages/8b/8b/3a48b1cdafa612679d976274355f6a822de90b85d7dba55654ecfb01c979/aiohttp-3.11.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ee9afa1b0d2293c46954f47f33e150798ad68b78925e3710044e0d67a9487791", size = 1712139 }, + { url = "https://files.pythonhosted.org/packages/aa/9d/70ab5b4dd7900db04af72840e033aee06e472b1343e372ea256ed675511c/aiohttp-3.11.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8360c7cc620abb320e1b8d603c39095101391a82b1d0be05fb2225471c9c5c52", size = 1667446 }, + { url = "https://files.pythonhosted.org/packages/cb/98/b5fbcc8f6056f0c56001c75227e6b7ca9ee4f2e5572feca82ff3d65d485d/aiohttp-3.11.7-cp313-cp313-win32.whl", hash = "sha256:7a9318da4b4ada9a67c1dd84d1c0834123081e746bee311a16bb449f363d965e", size = 408689 }, + { url = "https://files.pythonhosted.org/packages/ef/07/4d1504577fa6349dd2e3839e89fb56e5dee38d64efe3d4366e9fcfda0cdb/aiohttp-3.11.7-cp313-cp313-win_amd64.whl", hash = "sha256:fc6da202068e0a268e298d7cd09b6e9f3997736cd9b060e2750963754552a0a9", size = 434809 }, +] + +[[package]] +name = "aiosignal" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/67/0952ed97a9793b4958e5736f6d2b346b414a2cd63e82d05940032f45b32f/aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc", size = 19422 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/ac/a7305707cb852b7e16ff80eaf5692309bde30e2b1100a1fcacdc8f731d97/aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17", size = 7617 }, +] + +[[package]] +name = "alabaster" +version = "0.7.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/3e/13dd8e5ed9094e734ac430b5d0eb4f2bb001708a8b7856cbf8e084e001ba/alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65", size = 23776 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/34/d4e1c02d3bee589efb5dfa17f88ea08bdb3e3eac12bc475462aec52ed223/alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92", size = 13511 }, +] + +[[package]] +name = "anyio" +version = "4.6.2.post1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/09/45b9b7a6d4e45c6bcb5bf61d19e3ab87df68e0601fa8c5293de3542546cc/anyio-4.6.2.post1.tar.gz", hash = "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c", size = 173422 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/f5/f2b75d2fc6f1a260f340f0e7c6a060f4dd2961cc16884ed851b0d18da06a/anyio-4.6.2.post1-py3-none-any.whl", hash = "sha256:6d170c36fba3bdd840c73d3868c1e777e33676a69c3a72cf0a0d5d6d8009b61d", size = 90377 }, +] + +[[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.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "colorama", marker = "platform_system == 'Windows'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/bf/59d836c3433d7aa07f76c2b95c4eb763195ea8a5d7f9ad3311ed30c2af61/asyncclick-8.1.7.2.tar.gz", hash = "sha256:219ea0f29ccdc1bb4ff43bcab7ce0769ac6d48a04f997b43ec6bee99a222daa0", size = 349073 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/6e/9acdbb25733e1de411663b59abe521bec738e72fe4e85843f6ff8b212832/asyncclick-8.1.7.2-py3-none-any.whl", hash = "sha256:1ab940b04b22cb89b5b400725132b069d01b0c3472a9702c7a2c9d5d007ded02", size = 99191 }, +] + +[[package]] +name = "attrs" +version = "24.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/0f/aafca9af9315aee06a89ffde799a10a582fe8de76c563ee80bbcdc08b3fb/attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346", size = 792678 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/21/5b6702a7f963e95456c0de2d495f67bf5fd62840ac655dc451586d23d39a/attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2", size = 63001 }, +] + +[[package]] +name = "babel" +version = "2.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/74/f1bc80f23eeba13393b7222b11d95ca3af2c1e28edca18af487137eefed9/babel-2.16.0.tar.gz", hash = "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316", size = 9348104 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/20/bc79bc575ba2e2a7f70e8a1155618bb1301eaa5132a8271373a6903f73f8/babel-2.16.0-py3-none-any.whl", hash = "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b", size = 9587599 }, +] + +[[package]] +name = "certifi" +version = "2024.8.30" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 }, +] + +[[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.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/4f/e1808dc01273379acc506d18f1504eb2d299bd4131743b9fc54d7be4df1e/charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", size = 106620 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/61/73589dcc7a719582bf56aae309b6103d2762b526bffe189d635a7fcfd998/charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c", size = 193339 }, + { url = "https://files.pythonhosted.org/packages/77/d5/8c982d58144de49f59571f940e329ad6e8615e1e82ef84584c5eeb5e1d72/charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944", size = 124366 }, + { url = "https://files.pythonhosted.org/packages/bf/19/411a64f01ee971bed3231111b69eb56f9331a769072de479eae7de52296d/charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee", size = 118874 }, + { url = "https://files.pythonhosted.org/packages/4c/92/97509850f0d00e9f14a46bc751daabd0ad7765cff29cdfb66c68b6dad57f/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c", size = 138243 }, + { url = "https://files.pythonhosted.org/packages/e2/29/d227805bff72ed6d6cb1ce08eec707f7cfbd9868044893617eb331f16295/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6", size = 148676 }, + { url = "https://files.pythonhosted.org/packages/13/bc/87c2c9f2c144bedfa62f894c3007cd4530ba4b5351acb10dc786428a50f0/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea", size = 141289 }, + { url = "https://files.pythonhosted.org/packages/eb/5b/6f10bad0f6461fa272bfbbdf5d0023b5fb9bc6217c92bf068fa5a99820f5/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc", size = 142585 }, + { url = "https://files.pythonhosted.org/packages/3b/a0/a68980ab8a1f45a36d9745d35049c1af57d27255eff8c907e3add84cf68f/charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5", size = 144408 }, + { url = "https://files.pythonhosted.org/packages/d7/a1/493919799446464ed0299c8eef3c3fad0daf1c3cd48bff9263c731b0d9e2/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594", size = 139076 }, + { url = "https://files.pythonhosted.org/packages/fb/9d/9c13753a5a6e0db4a0a6edb1cef7aee39859177b64e1a1e748a6e3ba62c2/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c", size = 146874 }, + { url = "https://files.pythonhosted.org/packages/75/d2/0ab54463d3410709c09266dfb416d032a08f97fd7d60e94b8c6ef54ae14b/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365", size = 150871 }, + { url = "https://files.pythonhosted.org/packages/8d/c9/27e41d481557be53d51e60750b85aa40eaf52b841946b3cdeff363105737/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129", size = 148546 }, + { url = "https://files.pythonhosted.org/packages/ee/44/4f62042ca8cdc0cabf87c0fc00ae27cd8b53ab68be3605ba6d071f742ad3/charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236", size = 143048 }, + { url = "https://files.pythonhosted.org/packages/01/f8/38842422988b795220eb8038745d27a675ce066e2ada79516c118f291f07/charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99", size = 94389 }, + { url = "https://files.pythonhosted.org/packages/0b/6e/b13bd47fa9023b3699e94abf565b5a2f0b0be6e9ddac9812182596ee62e4/charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27", size = 101752 }, + { url = "https://files.pythonhosted.org/packages/d3/0b/4b7a70987abf9b8196845806198975b6aab4ce016632f817ad758a5aa056/charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6", size = 194445 }, + { url = "https://files.pythonhosted.org/packages/50/89/354cc56cf4dd2449715bc9a0f54f3aef3dc700d2d62d1fa5bbea53b13426/charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf", size = 125275 }, + { url = "https://files.pythonhosted.org/packages/fa/44/b730e2a2580110ced837ac083d8ad222343c96bb6b66e9e4e706e4d0b6df/charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db", size = 119020 }, + { url = "https://files.pythonhosted.org/packages/9d/e4/9263b8240ed9472a2ae7ddc3e516e71ef46617fe40eaa51221ccd4ad9a27/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1", size = 139128 }, + { url = "https://files.pythonhosted.org/packages/6b/e3/9f73e779315a54334240353eaea75854a9a690f3f580e4bd85d977cb2204/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03", size = 149277 }, + { url = "https://files.pythonhosted.org/packages/1a/cf/f1f50c2f295312edb8a548d3fa56a5c923b146cd3f24114d5adb7e7be558/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284", size = 142174 }, + { url = "https://files.pythonhosted.org/packages/16/92/92a76dc2ff3a12e69ba94e7e05168d37d0345fa08c87e1fe24d0c2a42223/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15", size = 143838 }, + { url = "https://files.pythonhosted.org/packages/a4/01/2117ff2b1dfc61695daf2babe4a874bca328489afa85952440b59819e9d7/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8", size = 146149 }, + { url = "https://files.pythonhosted.org/packages/f6/9b/93a332b8d25b347f6839ca0a61b7f0287b0930216994e8bf67a75d050255/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2", size = 140043 }, + { url = "https://files.pythonhosted.org/packages/ab/f6/7ac4a01adcdecbc7a7587767c776d53d369b8b971382b91211489535acf0/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719", size = 148229 }, + { url = "https://files.pythonhosted.org/packages/9d/be/5708ad18161dee7dc6a0f7e6cf3a88ea6279c3e8484844c0590e50e803ef/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631", size = 151556 }, + { url = "https://files.pythonhosted.org/packages/5a/bb/3d8bc22bacb9eb89785e83e6723f9888265f3a0de3b9ce724d66bd49884e/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b", size = 149772 }, + { url = "https://files.pythonhosted.org/packages/f7/fa/d3fc622de05a86f30beea5fc4e9ac46aead4731e73fd9055496732bcc0a4/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565", size = 144800 }, + { url = "https://files.pythonhosted.org/packages/9a/65/bdb9bc496d7d190d725e96816e20e2ae3a6fa42a5cac99c3c3d6ff884118/charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7", size = 94836 }, + { url = "https://files.pythonhosted.org/packages/3e/67/7b72b69d25b89c0b3cea583ee372c43aa24df15f0e0f8d3982c57804984b/charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9", size = 102187 }, + { url = "https://files.pythonhosted.org/packages/f3/89/68a4c86f1a0002810a27f12e9a7b22feb198c59b2f05231349fbce5c06f4/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", size = 194617 }, + { url = "https://files.pythonhosted.org/packages/4f/cd/8947fe425e2ab0aa57aceb7807af13a0e4162cd21eee42ef5b053447edf5/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", size = 125310 }, + { url = "https://files.pythonhosted.org/packages/5b/f0/b5263e8668a4ee9becc2b451ed909e9c27058337fda5b8c49588183c267a/charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", size = 119126 }, + { url = "https://files.pythonhosted.org/packages/ff/6e/e445afe4f7fda27a533f3234b627b3e515a1b9429bc981c9a5e2aa5d97b6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", size = 139342 }, + { url = "https://files.pythonhosted.org/packages/a1/b2/4af9993b532d93270538ad4926c8e37dc29f2111c36f9c629840c57cd9b3/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", size = 149383 }, + { url = "https://files.pythonhosted.org/packages/fb/6f/4e78c3b97686b871db9be6f31d64e9264e889f8c9d7ab33c771f847f79b7/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", size = 142214 }, + { url = "https://files.pythonhosted.org/packages/2b/c9/1c8fe3ce05d30c87eff498592c89015b19fade13df42850aafae09e94f35/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", size = 144104 }, + { url = "https://files.pythonhosted.org/packages/ee/68/efad5dcb306bf37db7db338338e7bb8ebd8cf38ee5bbd5ceaaaa46f257e6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", size = 146255 }, + { url = "https://files.pythonhosted.org/packages/0c/75/1ed813c3ffd200b1f3e71121c95da3f79e6d2a96120163443b3ad1057505/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", size = 140251 }, + { url = "https://files.pythonhosted.org/packages/7d/0d/6f32255c1979653b448d3c709583557a4d24ff97ac4f3a5be156b2e6a210/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", size = 148474 }, + { url = "https://files.pythonhosted.org/packages/ac/a0/c1b5298de4670d997101fef95b97ac440e8c8d8b4efa5a4d1ef44af82f0d/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", size = 151849 }, + { url = "https://files.pythonhosted.org/packages/04/4f/b3961ba0c664989ba63e30595a3ed0875d6790ff26671e2aae2fdc28a399/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", size = 149781 }, + { url = "https://files.pythonhosted.org/packages/d8/90/6af4cd042066a4adad58ae25648a12c09c879efa4849c705719ba1b23d8c/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482", size = 144970 }, + { url = "https://files.pythonhosted.org/packages/cc/67/e5e7e0cbfefc4ca79025238b43cdf8a2037854195b37d6417f3d0895c4c2/charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", size = 94973 }, + { url = "https://files.pythonhosted.org/packages/65/97/fc9bbc54ee13d33dc54a7fcf17b26368b18505500fc01e228c27b5222d80/charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", size = 102308 }, + { url = "https://files.pythonhosted.org/packages/bf/9b/08c0432272d77b04803958a4598a51e2a4b51c06640af8b8f0f908c18bf2/charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", size = 49446 }, +] + +[[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.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/75/aecfd0a3adbec6e45753976bc2a9fed62b42cea9a206d10fd29244a77953/coverage-7.6.8.tar.gz", hash = "sha256:8b2b8503edb06822c86d82fa64a4a5cb0760bb8f31f26e138ec743f422f37cfc", size = 801425 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/9f/e98211980f6e2f439e251737482aa77906c9b9c507824c71a2ce7eea0402/coverage-7.6.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:86cffe9c6dfcfe22e28027069725c7f57f4b868a3f86e81d1c62462764dc46d4", size = 207093 }, + { url = "https://files.pythonhosted.org/packages/fd/c7/8bab83fb9c20f7f8163c5a20dcb62d591b906a214a6dc6b07413074afc80/coverage-7.6.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d82ab6816c3277dc962cfcdc85b1efa0e5f50fb2c449432deaf2398a2928ab94", size = 207536 }, + { url = "https://files.pythonhosted.org/packages/1e/d6/00243df625f1b282bb25c83ce153ae2c06f8e7a796a8d833e7235337b4d9/coverage-7.6.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13690e923a3932e4fad4c0ebfb9cb5988e03d9dcb4c5150b5fcbf58fd8bddfc4", size = 239482 }, + { url = "https://files.pythonhosted.org/packages/1e/07/faf04b3eeb55ffc2a6f24b65dffe6e0359ec3b283e6efb5050ea0707446f/coverage-7.6.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4be32da0c3827ac9132bb488d331cb32e8d9638dd41a0557c5569d57cf22c9c1", size = 236886 }, + { url = "https://files.pythonhosted.org/packages/43/23/c79e497bf4d8fcacd316bebe1d559c765485b8ec23ac4e23025be6bfce09/coverage-7.6.8-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44e6c85bbdc809383b509d732b06419fb4544dca29ebe18480379633623baafb", size = 238749 }, + { url = "https://files.pythonhosted.org/packages/b5/e5/791bae13be3c6451e32ef7af1192e711c6a319f3c597e9b218d148fd0633/coverage-7.6.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:768939f7c4353c0fac2f7c37897e10b1414b571fd85dd9fc49e6a87e37a2e0d8", size = 237679 }, + { url = "https://files.pythonhosted.org/packages/05/c6/bbfdfb03aada601fb8993ced17468c8c8e0b4aafb3097026e680fabb7ce1/coverage-7.6.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e44961e36cb13c495806d4cac67640ac2866cb99044e210895b506c26ee63d3a", size = 236317 }, + { url = "https://files.pythonhosted.org/packages/67/f9/f8e5a4b2ce96d1b0e83ae6246369eb8437001dc80ec03bb51c87ff557cd8/coverage-7.6.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ea8bb1ab9558374c0ab591783808511d135a833c3ca64a18ec927f20c4030f0", size = 237084 }, + { url = "https://files.pythonhosted.org/packages/f0/70/b05328901e4debe76e033717e1452d00246c458c44e9dbd893e7619c2967/coverage-7.6.8-cp311-cp311-win32.whl", hash = "sha256:629a1ba2115dce8bf75a5cce9f2486ae483cb89c0145795603d6554bdc83e801", size = 209638 }, + { url = "https://files.pythonhosted.org/packages/70/55/1efa24f960a2fa9fbc44a9523d3f3c50ceb94dd1e8cd732168ab2dc41b07/coverage-7.6.8-cp311-cp311-win_amd64.whl", hash = "sha256:fb9fc32399dca861584d96eccd6c980b69bbcd7c228d06fb74fe53e007aa8ef9", size = 210506 }, + { url = "https://files.pythonhosted.org/packages/76/ce/3edf581c8fe429ed8ced6e6d9ac693c25975ef9093413276dab6ed68a80a/coverage-7.6.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e683e6ecc587643f8cde8f5da6768e9d165cd31edf39ee90ed7034f9ca0eefee", size = 207285 }, + { url = "https://files.pythonhosted.org/packages/09/9c/cf102ab046c9cf8895c3f7aadcde6f489a4b2ec326757e8c6e6581829b5e/coverage-7.6.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1defe91d41ce1bd44b40fabf071e6a01a5aa14de4a31b986aa9dfd1b3e3e414a", size = 207522 }, + { url = "https://files.pythonhosted.org/packages/39/06/42aa6dd13dbfca72e1fd8ffccadbc921b6e75db34545ebab4d955d1e7ad3/coverage-7.6.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7ad66e8e50225ebf4236368cc43c37f59d5e6728f15f6e258c8639fa0dd8e6d", size = 240543 }, + { url = "https://files.pythonhosted.org/packages/a0/20/2932971dc215adeca8eeff446266a7fef17a0c238e881ffedebe7bfa0669/coverage-7.6.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fe47da3e4fda5f1abb5709c156eca207eacf8007304ce3019eb001e7a7204cb", size = 237577 }, + { url = "https://files.pythonhosted.org/packages/ac/85/4323ece0cd5452c9522f4b6e5cc461e6c7149a4b1887c9e7a8b1f4e51146/coverage-7.6.8-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:202a2d645c5a46b84992f55b0a3affe4f0ba6b4c611abec32ee88358db4bb649", size = 239646 }, + { url = "https://files.pythonhosted.org/packages/77/52/b2537487d8f36241e518e84db6f79e26bc3343b14844366e35b090fae0d4/coverage-7.6.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4674f0daa1823c295845b6a740d98a840d7a1c11df00d1fd62614545c1583787", size = 239128 }, + { url = "https://files.pythonhosted.org/packages/7c/99/7f007762012186547d0ecc3d328da6b6f31a8c99f05dc1e13dcd929918cd/coverage-7.6.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:74610105ebd6f33d7c10f8907afed696e79c59e3043c5f20eaa3a46fddf33b4c", size = 237434 }, + { url = "https://files.pythonhosted.org/packages/97/53/e9b5cf0682a1cab9352adfac73caae0d77ae1d65abc88975d510f7816389/coverage-7.6.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37cda8712145917105e07aab96388ae76e787270ec04bcb9d5cc786d7cbb8443", size = 239095 }, + { url = "https://files.pythonhosted.org/packages/0c/50/054f0b464fbae0483217186478eefa2e7df3a79917ed7f1d430b6da2cf0d/coverage-7.6.8-cp312-cp312-win32.whl", hash = "sha256:9e89d5c8509fbd6c03d0dd1972925b22f50db0792ce06324ba069f10787429ad", size = 209895 }, + { url = "https://files.pythonhosted.org/packages/df/d0/09ba870360a27ecf09e177ca2ff59d4337fc7197b456f22ceff85cffcfa5/coverage-7.6.8-cp312-cp312-win_amd64.whl", hash = "sha256:379c111d3558272a2cae3d8e57e6b6e6f4fe652905692d54bad5ea0ca37c5ad4", size = 210684 }, + { url = "https://files.pythonhosted.org/packages/9a/84/6f0ccf94a098ac3d6d6f236bd3905eeac049a9e0efcd9a63d4feca37ac4b/coverage-7.6.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0b0c69f4f724c64dfbfe79f5dfb503b42fe6127b8d479b2677f2b227478db2eb", size = 207313 }, + { url = "https://files.pythonhosted.org/packages/db/2b/e3b3a3a12ebec738c545897ac9f314620470fcbc368cdac88cf14974ba20/coverage-7.6.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c15b32a7aca8038ed7644f854bf17b663bc38e1671b5d6f43f9a2b2bd0c46f63", size = 207574 }, + { url = "https://files.pythonhosted.org/packages/db/c0/5bf95d42b6a8d21dfce5025ce187f15db57d6460a59b67a95fe8728162f1/coverage-7.6.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63068a11171e4276f6ece913bde059e77c713b48c3a848814a6537f35afb8365", size = 240090 }, + { url = "https://files.pythonhosted.org/packages/57/b8/d6fd17d1a8e2b0e1a4e8b9cb1f0f261afd422570735899759c0584236916/coverage-7.6.8-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f4548c5ead23ad13fb7a2c8ea541357474ec13c2b736feb02e19a3085fac002", size = 237237 }, + { url = "https://files.pythonhosted.org/packages/d4/e4/a91e9bb46809c8b63e68fc5db5c4d567d3423b6691d049a4f950e38fbe9d/coverage-7.6.8-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b4b4299dd0d2c67caaaf286d58aef5e75b125b95615dda4542561a5a566a1e3", size = 239225 }, + { url = "https://files.pythonhosted.org/packages/31/9c/9b99b0591ec4555b7292d271e005f27b465388ce166056c435b288db6a69/coverage-7.6.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9ebfb2507751f7196995142f057d1324afdab56db1d9743aab7f50289abd022", size = 238888 }, + { url = "https://files.pythonhosted.org/packages/a6/85/285c2df9a04bc7c31f21fd9d4a24d19e040ec5e2ff06e572af1f6514c9e7/coverage-7.6.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c1b4474beee02ede1eef86c25ad4600a424fe36cff01a6103cb4533c6bf0169e", size = 236974 }, + { url = "https://files.pythonhosted.org/packages/cb/a1/95ec8522206f76cdca033bf8bb61fff56429fb414835fc4d34651dfd29fc/coverage-7.6.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d9fd2547e6decdbf985d579cf3fc78e4c1d662b9b0ff7cc7862baaab71c9cc5b", size = 238815 }, + { url = "https://files.pythonhosted.org/packages/8d/ac/687e9ba5e6d0979e9dab5c02e01c4f24ac58260ef82d88d3b433b3f84f1e/coverage-7.6.8-cp313-cp313-win32.whl", hash = "sha256:8aae5aea53cbfe024919715eca696b1a3201886ce83790537d1c3668459c7146", size = 209957 }, + { url = "https://files.pythonhosted.org/packages/2f/a3/b61cc8e3fcf075293fb0f3dee405748453c5ba28ac02ceb4a87f52bdb105/coverage-7.6.8-cp313-cp313-win_amd64.whl", hash = "sha256:ae270e79f7e169ccfe23284ff5ea2d52a6f401dc01b337efb54b3783e2ce3f28", size = 210711 }, + { url = "https://files.pythonhosted.org/packages/ee/4b/891c8b9acf1b62c85e4a71dac142ab9284e8347409b7355de02e3f38306f/coverage-7.6.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de38add67a0af869b0d79c525d3e4588ac1ffa92f39116dbe0ed9753f26eba7d", size = 208053 }, + { url = "https://files.pythonhosted.org/packages/18/a9/9e330409b291cc002723d339346452800e78df1ce50774ca439ade1d374f/coverage-7.6.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b07c25d52b1c16ce5de088046cd2432b30f9ad5e224ff17c8f496d9cb7d1d451", size = 208329 }, + { url = "https://files.pythonhosted.org/packages/9c/0d/33635fd429f6589c6e1cdfc7bf581aefe4c1792fbff06383f9d37f59db60/coverage-7.6.8-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62a66ff235e4c2e37ed3b6104d8b478d767ff73838d1222132a7a026aa548764", size = 251052 }, + { url = "https://files.pythonhosted.org/packages/23/32/8a08da0e46f3830bbb9a5b40614241b2e700f27a9c2889f53122486443ed/coverage-7.6.8-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09b9f848b28081e7b975a3626e9081574a7b9196cde26604540582da60235fdf", size = 246765 }, + { url = "https://files.pythonhosted.org/packages/56/3f/3b86303d2c14350fdb1c6c4dbf9bc76000af2382f42ca1d4d99c6317666e/coverage-7.6.8-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:093896e530c38c8e9c996901858ac63f3d4171268db2c9c8b373a228f459bbc5", size = 249125 }, + { url = "https://files.pythonhosted.org/packages/36/cb/c4f081b9023f9fd8646dbc4ef77be0df090263e8f66f4ea47681e0dc2cff/coverage-7.6.8-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a7b8ac36fd688c8361cbc7bf1cb5866977ece6e0b17c34aa0df58bda4fa18a4", size = 248615 }, + { url = "https://files.pythonhosted.org/packages/32/ee/53bdbf67760928c44b57b2c28a8c0a4bf544f85a9ee129a63ba5c78fdee4/coverage-7.6.8-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:38c51297b35b3ed91670e1e4efb702b790002e3245a28c76e627478aa3c10d83", size = 246507 }, + { url = "https://files.pythonhosted.org/packages/57/49/5a57910bd0af6d8e802b4ca65292576d19b54b49f81577fd898505dee075/coverage-7.6.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2e4e0f60cb4bd7396108823548e82fdab72d4d8a65e58e2c19bbbc2f1e2bfa4b", size = 247785 }, + { url = "https://files.pythonhosted.org/packages/bd/37/e450c9f6b297c79bb9858407396ed3e084dcc22990dd110ab01d5ceb9770/coverage-7.6.8-cp313-cp313t-win32.whl", hash = "sha256:6535d996f6537ecb298b4e287a855f37deaf64ff007162ec0afb9ab8ba3b8b71", size = 210605 }, + { url = "https://files.pythonhosted.org/packages/44/79/7d0c7dd237c6905018e2936cd1055fe1d42e7eba2ebab3c00f4aad2a27d7/coverage-7.6.8-cp313-cp313t-win_amd64.whl", hash = "sha256:c79c0685f142ca53256722a384540832420dff4ab15fec1863d7e5bc8691bdcc", size = 211777 }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cryptography" +version = "43.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/05/07b55d1fa21ac18c3a8c79f764e2514e6f6a9698f1be44994f5adf0d29db/cryptography-43.0.3.tar.gz", hash = "sha256:315b9001266a492a6ff443b61238f956b214dbec9910a081ba5b6646a055a805", size = 686989 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f3/01fdf26701a26f4b4dbc337a26883ad5bccaa6f1bbbdd29cd89e22f18a1c/cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e", size = 6225303 }, + { url = "https://files.pythonhosted.org/packages/a3/01/4896f3d1b392025d4fcbecf40fdea92d3df8662123f6835d0af828d148fd/cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e", size = 3760905 }, + { url = "https://files.pythonhosted.org/packages/0a/be/f9a1f673f0ed4b7f6c643164e513dbad28dd4f2dcdf5715004f172ef24b6/cryptography-43.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e1ce50266f4f70bf41a2c6dc4358afadae90e2a1e5342d3c08883df1675374f", size = 3977271 }, + { url = "https://files.pythonhosted.org/packages/4e/49/80c3a7b5514d1b416d7350830e8c422a4d667b6d9b16a9392ebfd4a5388a/cryptography-43.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:443c4a81bb10daed9a8f334365fe52542771f25aedaf889fd323a853ce7377d6", size = 3746606 }, + { url = "https://files.pythonhosted.org/packages/0e/16/a28ddf78ac6e7e3f25ebcef69ab15c2c6be5ff9743dd0709a69a4f968472/cryptography-43.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:74f57f24754fe349223792466a709f8e0c093205ff0dca557af51072ff47ab18", size = 3986484 }, + { url = "https://files.pythonhosted.org/packages/01/f5/69ae8da70c19864a32b0315049866c4d411cce423ec169993d0434218762/cryptography-43.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9762ea51a8fc2a88b70cf2995e5675b38d93bf36bd67d91721c309df184f49bd", size = 3852131 }, + { url = "https://files.pythonhosted.org/packages/fd/db/e74911d95c040f9afd3612b1f732e52b3e517cb80de8bf183be0b7d413c6/cryptography-43.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:81ef806b1fef6b06dcebad789f988d3b37ccaee225695cf3e07648eee0fc6b73", size = 4075647 }, + { url = "https://files.pythonhosted.org/packages/56/48/7b6b190f1462818b324e674fa20d1d5ef3e24f2328675b9b16189cbf0b3c/cryptography-43.0.3-cp37-abi3-win32.whl", hash = "sha256:cbeb489927bd7af4aa98d4b261af9a5bc025bd87f0e3547e11584be9e9427be2", size = 2623873 }, + { url = "https://files.pythonhosted.org/packages/eb/b1/0ebff61a004f7f89e7b65ca95f2f2375679d43d0290672f7713ee3162aff/cryptography-43.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:f46304d6f0c6ab8e52770addfa2fc41e6629495548862279641972b6215451cd", size = 3068039 }, + { url = "https://files.pythonhosted.org/packages/30/d5/c8b32c047e2e81dd172138f772e81d852c51f0f2ad2ae8a24f1122e9e9a7/cryptography-43.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:8ac43ae87929a5982f5948ceda07001ee5e83227fd69cf55b109144938d96984", size = 6222984 }, + { url = "https://files.pythonhosted.org/packages/2f/78/55356eb9075d0be6e81b59f45c7b48df87f76a20e73893872170471f3ee8/cryptography-43.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:846da004a5804145a5f441b8530b4bf35afbf7da70f82409f151695b127213d5", size = 3762968 }, + { url = "https://files.pythonhosted.org/packages/2a/2c/488776a3dc843f95f86d2f957ca0fc3407d0242b50bede7fad1e339be03f/cryptography-43.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f996e7268af62598f2fc1204afa98a3b5712313a55c4c9d434aef49cadc91d4", size = 3977754 }, + { url = "https://files.pythonhosted.org/packages/7c/04/2345ca92f7a22f601a9c62961741ef7dd0127c39f7310dffa0041c80f16f/cryptography-43.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f7b178f11ed3664fd0e995a47ed2b5ff0a12d893e41dd0494f406d1cf555cab7", size = 3749458 }, + { url = "https://files.pythonhosted.org/packages/ac/25/e715fa0bc24ac2114ed69da33adf451a38abb6f3f24ec207908112e9ba53/cryptography-43.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:c2e6fc39c4ab499049df3bdf567f768a723a5e8464816e8f009f121a5a9f4405", size = 3988220 }, + { url = "https://files.pythonhosted.org/packages/21/ce/b9c9ff56c7164d8e2edfb6c9305045fbc0df4508ccfdb13ee66eb8c95b0e/cryptography-43.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e1be4655c7ef6e1bbe6b5d0403526601323420bcf414598955968c9ef3eb7d16", size = 3853898 }, + { url = "https://files.pythonhosted.org/packages/2a/33/b3682992ab2e9476b9c81fff22f02c8b0a1e6e1d49ee1750a67d85fd7ed2/cryptography-43.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:df6b6c6d742395dd77a23ea3728ab62f98379eff8fb61be2744d4679ab678f73", size = 4076592 }, + { url = "https://files.pythonhosted.org/packages/81/1e/ffcc41b3cebd64ca90b28fd58141c5f68c83d48563c88333ab660e002cd3/cryptography-43.0.3-cp39-abi3-win32.whl", hash = "sha256:d56e96520b1020449bbace2b78b603442e7e378a9b3bd68de65c782db1507995", size = 2623145 }, + { url = "https://files.pythonhosted.org/packages/87/5c/3dab83cc4aba1f4b0e733e3f0c3e7d4386440d660ba5b1e3ff995feb734d/cryptography-43.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:0c580952eef9bf68c4747774cde7ec1d85a6e61de97281f2dba83c7d2c806362", size = 3068026 }, +] + +[[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.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/db/3ef5bb276dae18d6ec2124224403d1d67bccdbefc17af4cc8f553e341ab1/filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435", size = 18037 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/f8/feced7779d755758a52d1f6635d990b8d98dc0a29fa568bbe0625f18fdf3/filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", size = 16163 }, +] + +[[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.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/5f/05f0d167be94585d502b4adf8c7af31f1dc0b1c7e14f9938a88fdbbcf4a7/identify-2.6.3.tar.gz", hash = "sha256:62f5dae9b5fef52c84cc188514e9ea4f3f636b1d8799ab5ebc475471f9e47a02", size = 99179 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/f5/09644a3ad803fae9eca8efa17e1f2aef380c7f0b02f7ec4e8d446e51d64a/identify-2.6.3-py2.py3-none-any.whl", hash = "sha256:9edba65473324c2ea9684b1f944fe3191db3345e50b6d04571d10ed164f8d7bd", size = 99049 }, +] + +[[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.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/55/39036716d19cab0747a5020fc7e907f362fbf48c984b14e62127f7e68e5d/jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369", size = 240245 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/80/3a54838c3fb461f6fec263ebf3a3a41771bd05190238de3486aae8540c36/jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d", size = 133271 }, +] + +[[package]] +name = "kasa-crypt" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ba/f78a63c5b55dc18b39099a1a1bf6569c14ccca47dd342cc4f4d774ec5719/kasa_crypt-0.4.4.tar.gz", hash = "sha256:cc31749e44a309459a71802ae8471a9d5ad6a7656938a44af64b93a8c3873ccd", size = 9306 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/d9e9b54aad36d8aae9f59adc8ddb27bf7a06f505deffe98f28bc865ba494/kasa_crypt-0.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:04fad5f981e734ab1b269922a1175bc506d5498681778b3d61561422619d6e6d", size = 69934 }, + { url = "https://files.pythonhosted.org/packages/15/79/5e94eb76f2935f92de9602b04d0c244653540128eba2be71e6284f9c9997/kasa_crypt-0.4.4-cp311-cp311-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:a54040539fe8293a7dd20fcf5e613ba4bdcafe15a8d9eeff1cc2805500a0c2d9", size = 133178 }, + { url = "https://files.pythonhosted.org/packages/7a/1e/3836b1e69da964e3c8dbf057d82f8f13d277fe9baa6c327400ea5ebc37e1/kasa_crypt-0.4.4-cp311-cp311-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a0a0981255225fd5671ffed85f2bfc68b0ac8525b5d424a703aaa1d0f8f4cc2", size = 136881 }, + { url = "https://files.pythonhosted.org/packages/aa/24/eeafbbdc5a914abdd8911108eab7fe3ddf5bfdd1e14d3d43f5874a936863/kasa_crypt-0.4.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:fa2bcbf7c4bb2af4a86c553fb8df47466c06f5060d5c21253a4ecd9ee2237ef4", size = 136189 }, + { url = "https://files.pythonhosted.org/packages/69/23/6c0604c093f69f80d00b8953ec7ac0cfc4db2504db7cddf7be26f6ed582d/kasa_crypt-0.4.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:99518489cb93d93c6c2e5ac4e30ad6838bb64c8365e8c3a37204e7f4228805ca", size = 139644 }, + { url = "https://files.pythonhosted.org/packages/c4/54/13e48c5b280600c966cba23b1940d38ec2847db909f060224c902af33c5c/kasa_crypt-0.4.4-cp311-cp311-win32.whl", hash = "sha256:431223a614f868a253786da7b137a8597c8ce83ed71a8bc10ffe9e56f7a8ba4d", size = 68754 }, + { url = "https://files.pythonhosted.org/packages/02/eb/aa085ddebda8c1d2912e5c6196f3c9106595c6dae2098bcb5df602db978f/kasa_crypt-0.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:c3d60a642985c3c7c9b598e19da537566803d2f78a42d0be5a7231d717239f11", size = 70959 }, + { url = "https://files.pythonhosted.org/packages/aa/f6/de1ecffa3b69200a9ebeb423f8bdb3a46987508865c906c50c09f18e311f/kasa_crypt-0.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:038a16270b15d9a9845ad4ba66f76cbf05109855e40afb6a62d7b99e73ba55a3", size = 70165 }, + { url = "https://files.pythonhosted.org/packages/8a/9a/a43be44b356bb97f7a6213c7a87863c4f7f85c9137e75fb95d66e3f04d9b/kasa_crypt-0.4.4-cp312-cp312-manylinux_2_17_i686.manylinux_2_5_i686.manylinux1_i686.manylinux2014_i686.whl", hash = "sha256:5cc150ef1bd2a330903557f806e7b671fe59f15fd37337f69ea0d7872cbffdde", size = 139126 }, + { url = "https://files.pythonhosted.org/packages/0a/52/b6e8ee4bb8aea9735da157918342baa98bf3cc8e725d74315cd33a62374a/kasa_crypt-0.4.4-cp312-cp312-manylinux_2_17_x86_64.manylinux_2_5_x86_64.manylinux1_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c45838d4b361f76615be72ee9b238681c47330f09cc3b0eb830095b063a262c2", size = 143953 }, + { url = "https://files.pythonhosted.org/packages/b0/cb/2c10cb2534a1237c46f4e9d764e74f5f8e3eb84862fa656629e8f1b3ebb9/kasa_crypt-0.4.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:138479985246ebc6be5d9bb896e48860d72a280e068d798af93acd2a210031c1", size = 141496 }, + { url = "https://files.pythonhosted.org/packages/38/62/9bcf83c27ddfaa50353deb4c9793873356d7c4b99c3b073a1c623eda883c/kasa_crypt-0.4.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:806dd2f7a8c6d2242513a78c144a63664817b3f0b6e149166b87db9a6017d742", size = 146398 }, + { url = "https://files.pythonhosted.org/packages/d5/63/ad0de4d97f9ec2e290a9ed37756c70ad5c99403f62399a4f9fafeb3d8c81/kasa_crypt-0.4.4-cp312-cp312-win32.whl", hash = "sha256:791900085be025dbf7052f1e44c176e957556b1d04b6da4a602fc4ddc23f87b0", size = 68951 }, + { url = "https://files.pythonhosted.org/packages/44/ce/a843f0a2c3328d792a41ca6261c1564af188a4f15b1af34f83ec8c68c686/kasa_crypt-0.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:9c7d136bfcd74ac30ed5c10cb96c46a4e2eb90bd52974a0dbbc9c6d3e90d7699", size = 71352 }, +] + +[[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.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/21/7e9e523537991d145ab8a0a2fd98548d67646dc2aaaf6091c31ad883e7c1/mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e", size = 3152532 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/19/de0822609e5b93d02579075248c7aa6ceaddcea92f00bf4ea8e4c22e3598/mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d", size = 10939027 }, + { url = "https://files.pythonhosted.org/packages/c8/71/6950fcc6ca84179137e4cbf7cf41e6b68b4a339a1f5d3e954f8c34e02d66/mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d", size = 10108699 }, + { url = "https://files.pythonhosted.org/packages/26/50/29d3e7dd166e74dc13d46050b23f7d6d7533acf48f5217663a3719db024e/mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b", size = 12506263 }, + { url = "https://files.pythonhosted.org/packages/3f/1d/676e76f07f7d5ddcd4227af3938a9c9640f293b7d8a44dd4ff41d4db25c1/mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73", size = 12984688 }, + { url = "https://files.pythonhosted.org/packages/9c/03/5a85a30ae5407b1d28fab51bd3e2103e52ad0918d1e68f02a7778669a307/mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca", size = 9626811 }, + { url = "https://files.pythonhosted.org/packages/fb/31/c526a7bd2e5c710ae47717c7a5f53f616db6d9097caf48ad650581e81748/mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5", size = 11077900 }, + { url = "https://files.pythonhosted.org/packages/83/67/b7419c6b503679d10bd26fc67529bc6a1f7a5f220bbb9f292dc10d33352f/mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e", size = 10074818 }, + { url = "https://files.pythonhosted.org/packages/ba/07/37d67048786ae84e6612575e173d713c9a05d0ae495dde1e68d972207d98/mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2", size = 12589275 }, + { url = "https://files.pythonhosted.org/packages/1f/17/b1018c6bb3e9f1ce3956722b3bf91bff86c1cefccca71cec05eae49d6d41/mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0", size = 13037783 }, + { url = "https://files.pythonhosted.org/packages/cb/32/cd540755579e54a88099aee0287086d996f5a24281a673f78a0e14dba150/mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2", size = 9726197 }, + { url = "https://files.pythonhosted.org/packages/11/bb/ab4cfdc562cad80418f077d8be9b4491ee4fb257440da951b85cbb0a639e/mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7", size = 11069721 }, + { url = "https://files.pythonhosted.org/packages/59/3b/a393b1607cb749ea2c621def5ba8c58308ff05e30d9dbdc7c15028bca111/mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62", size = 10063996 }, + { url = "https://files.pythonhosted.org/packages/d1/1f/6b76be289a5a521bb1caedc1f08e76ff17ab59061007f201a8a18cc514d1/mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8", size = 12584043 }, + { url = "https://files.pythonhosted.org/packages/a6/83/5a85c9a5976c6f96e3a5a7591aa28b4a6ca3a07e9e5ba0cec090c8b596d6/mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7", size = 13036996 }, + { url = "https://files.pythonhosted.org/packages/b4/59/c39a6f752f1f893fccbcf1bdd2aca67c79c842402b5283563d006a67cf76/mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc", size = 9737709 }, + { url = "https://files.pythonhosted.org/packages/3b/86/72ce7f57431d87a7ff17d442f521146a6585019eb8f4f31b7c02801f78ad/mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a", size = 2647043 }, +] + +[[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.0" +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/85/55/6d1741a1780e5e65038b74bce6689da15f620261c490c3511eb4c12bac4b/myst_parser-4.0.0.tar.gz", hash = "sha256:851c9dfb44e36e56d15d05e72f02b80da21a9e0d07cba96baf5e2d476bb91531", size = 93858 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/b4/b036f8fdb667587bb37df29dc6644681dd78b7a2a6321a34684b79412b28/myst_parser-4.0.0-py3-none-any.whl", hash = "sha256:b9317997552424448c6096c2558872fdb6f81d3ecb3a40ce84a7518798f3f28d", size = 84563 }, +] + +[[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.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/04/bb9f72987e7f62fb591d6c880c0caaa16238e4e530cbc3bdc84a7372d75f/orjson-3.10.12.tar.gz", hash = "sha256:0a78bbda3aea0f9f079057ee1ee8a1ecf790d4f1af88dd67493c6b8ee52506ff", size = 5438647 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/48/7c3cd094488f5a3bc58488555244609a8c4d105bc02f2b77e509debf0450/orjson-3.10.12-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a734c62efa42e7df94926d70fe7d37621c783dea9f707a98cdea796964d4cf74", size = 248687 }, + { url = "https://files.pythonhosted.org/packages/ff/90/e55f0e25c7fdd1f82551fe787f85df6f378170caca863c04c810cd8f2730/orjson-3.10.12-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:750f8b27259d3409eda8350c2919a58b0cfcd2054ddc1bd317a643afc646ef23", size = 136953 }, + { url = "https://files.pythonhosted.org/packages/2a/b3/109c020cf7fee747d400de53b43b183ca9d3ebda3906ad0b858eb5479718/orjson-3.10.12-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb52c22bfffe2857e7aa13b4622afd0dd9d16ea7cc65fd2bf318d3223b1b6252", size = 149090 }, + { url = "https://files.pythonhosted.org/packages/96/d4/35c0275dc1350707d182a1b5da16d1184b9439848060af541285407f18f9/orjson-3.10.12-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:440d9a337ac8c199ff8251e100c62e9488924c92852362cd27af0e67308c16ef", size = 140480 }, + { url = "https://files.pythonhosted.org/packages/3b/79/f863ff460c291ad2d882cc3b580cc444bd4ec60c9df55f6901e6c9a3f519/orjson-3.10.12-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9e15c06491c69997dfa067369baab3bf094ecb74be9912bdc4339972323f252", size = 156564 }, + { url = "https://files.pythonhosted.org/packages/98/7e/8d5835449ddd873424ee7b1c4ba73a0369c1055750990d824081652874d6/orjson-3.10.12-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:362d204ad4b0b8724cf370d0cd917bb2dc913c394030da748a3bb632445ce7c4", size = 131279 }, + { url = "https://files.pythonhosted.org/packages/46/f5/d34595b6d7f4f984c6fef289269a7f98abcdc2445ebdf90e9273487dda6b/orjson-3.10.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2b57cbb4031153db37b41622eac67329c7810e5f480fda4cfd30542186f006ae", size = 139764 }, + { url = "https://files.pythonhosted.org/packages/b3/5b/ee6e9ddeab54a7b7806768151c2090a2d36025bc346a944f51cf172ef7f7/orjson-3.10.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:165c89b53ef03ce0d7c59ca5c82fa65fe13ddf52eeb22e859e58c237d4e33b9b", size = 131915 }, + { url = "https://files.pythonhosted.org/packages/c4/45/febee5951aef6db5cd8cdb260548101d7ece0ca9d4ddadadf1766306b7a4/orjson-3.10.12-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5dee91b8dfd54557c1a1596eb90bcd47dbcd26b0baaed919e6861f076583e9da", size = 415783 }, + { url = "https://files.pythonhosted.org/packages/27/a5/5a8569e49f3a6c093bee954a3de95062a231196f59e59df13a48e2420081/orjson-3.10.12-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:77a4e1cfb72de6f905bdff061172adfb3caf7a4578ebf481d8f0530879476c07", size = 142387 }, + { url = "https://files.pythonhosted.org/packages/6e/05/02550fb38c5bf758f3994f55401233a2ef304e175f473f2ac6dbf464cc8b/orjson-3.10.12-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:038d42c7bc0606443459b8fe2d1f121db474c49067d8d14c6a075bbea8bf14dd", size = 130664 }, + { url = "https://files.pythonhosted.org/packages/8c/f4/ba31019d0646ce51f7ac75af6dabf98fd89dbf8ad87a9086da34710738e7/orjson-3.10.12-cp311-none-win32.whl", hash = "sha256:03b553c02ab39bed249bedd4abe37b2118324d1674e639b33fab3d1dafdf4d79", size = 143623 }, + { url = "https://files.pythonhosted.org/packages/83/fe/babf08842b989acf4c46103fefbd7301f026423fab47e6f3ba07b54d7837/orjson-3.10.12-cp311-none-win_amd64.whl", hash = "sha256:8b8713b9e46a45b2af6b96f559bfb13b1e02006f4242c156cbadef27800a55a8", size = 135074 }, + { url = "https://files.pythonhosted.org/packages/a1/2f/989adcafad49afb535da56b95d8f87d82e748548b2a86003ac129314079c/orjson-3.10.12-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:53206d72eb656ca5ac7d3a7141e83c5bbd3ac30d5eccfe019409177a57634b0d", size = 248678 }, + { url = "https://files.pythonhosted.org/packages/69/b9/8c075e21a50c387649db262b618ebb7e4d40f4197b949c146fc225dd23da/orjson-3.10.12-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac8010afc2150d417ebda810e8df08dd3f544e0dd2acab5370cfa6bcc0662f8f", size = 136763 }, + { url = "https://files.pythonhosted.org/packages/87/d3/78edf10b4ab14c19f6d918cf46a145818f4aca2b5a1773c894c5490d3a4c/orjson-3.10.12-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed459b46012ae950dd2e17150e838ab08215421487371fa79d0eced8d1461d70", size = 149137 }, + { url = "https://files.pythonhosted.org/packages/16/81/5db8852bdf990a0ddc997fa8f16b80895b8cc77c0fe3701569ed2b4b9e78/orjson-3.10.12-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8dcb9673f108a93c1b52bfc51b0af422c2d08d4fc710ce9c839faad25020bb69", size = 140567 }, + { url = "https://files.pythonhosted.org/packages/fa/a6/9ce1e3e3db918512efadad489630c25841eb148513d21dab96f6b4157fa1/orjson-3.10.12-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:22a51ae77680c5c4652ebc63a83d5255ac7d65582891d9424b566fb3b5375ee9", size = 156620 }, + { url = "https://files.pythonhosted.org/packages/47/d4/05133d6bea24e292d2f7628b1e19986554f7d97b6412b3e51d812e38db2d/orjson-3.10.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:910fdf2ac0637b9a77d1aad65f803bac414f0b06f720073438a7bd8906298192", size = 131555 }, + { url = "https://files.pythonhosted.org/packages/b9/7a/b3fbffda8743135c7811e95dc2ab7cdbc5f04999b83c2957d046f1b3fac9/orjson-3.10.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:24ce85f7100160936bc2116c09d1a8492639418633119a2224114f67f63a4559", size = 139743 }, + { url = "https://files.pythonhosted.org/packages/b5/13/95bbcc9a6584aa083da5ce5004ce3d59ea362a542a0b0938d884fd8790b6/orjson-3.10.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a76ba5fc8dd9c913640292df27bff80a685bed3a3c990d59aa6ce24c352f8fc", size = 131733 }, + { url = "https://files.pythonhosted.org/packages/e8/29/dddbb2ea6e7af426fcc3da65a370618a88141de75c6603313d70768d1df1/orjson-3.10.12-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ff70ef093895fd53f4055ca75f93f047e088d1430888ca1229393a7c0521100f", size = 415788 }, + { url = "https://files.pythonhosted.org/packages/53/df/4aea59324ac539975919b4705ee086aced38e351a6eb3eea0f5071dd5661/orjson-3.10.12-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f4244b7018b5753ecd10a6d324ec1f347da130c953a9c88432c7fbc8875d13be", size = 142347 }, + { url = "https://files.pythonhosted.org/packages/55/55/a52d83d7c49f8ff44e0daab10554490447d6c658771569e1c662aa7057fe/orjson-3.10.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:16135ccca03445f37921fa4b585cff9a58aa8d81ebcb27622e69bfadd220b32c", size = 130829 }, + { url = "https://files.pythonhosted.org/packages/a1/8b/b1beb1624dd4adf7d72e2d9b73c4b529e7851c0c754f17858ea13e368b33/orjson-3.10.12-cp312-none-win32.whl", hash = "sha256:2d879c81172d583e34153d524fcba5d4adafbab8349a7b9f16ae511c2cee8708", size = 143659 }, + { url = "https://files.pythonhosted.org/packages/13/91/634c9cd0bfc6a857fc8fab9bf1a1bd9f7f3345e0d6ca5c3d4569ceb6dcfa/orjson-3.10.12-cp312-none-win_amd64.whl", hash = "sha256:fc23f691fa0f5c140576b8c365bc942d577d861a9ee1142e4db468e4e17094fb", size = 135221 }, + { url = "https://files.pythonhosted.org/packages/1b/bb/3f560735f46fa6f875a9d7c4c2171a58cfb19f56a633d5ad5037a924f35f/orjson-3.10.12-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:47962841b2a8aa9a258b377f5188db31ba49af47d4003a32f55d6f8b19006543", size = 248662 }, + { url = "https://files.pythonhosted.org/packages/a3/df/54817902350636cc9270db20486442ab0e4db33b38555300a1159b439d16/orjson-3.10.12-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6334730e2532e77b6054e87ca84f3072bee308a45a452ea0bffbbbc40a67e296", size = 126055 }, + { url = "https://files.pythonhosted.org/packages/2e/77/55835914894e00332601a74540840f7665e81f20b3e2b9a97614af8565ed/orjson-3.10.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:accfe93f42713c899fdac2747e8d0d5c659592df2792888c6c5f829472e4f85e", size = 131507 }, + { url = "https://files.pythonhosted.org/packages/33/9e/b91288361898e3158062a876b5013c519a5d13e692ac7686e3486c4133ab/orjson-3.10.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a7974c490c014c48810d1dede6c754c3cc46598da758c25ca3b4001ac45b703f", size = 131686 }, + { url = "https://files.pythonhosted.org/packages/b2/15/08ce117d60a4d2d3fd24e6b21db463139a658e9f52d22c9c30af279b4187/orjson-3.10.12-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3f250ce7727b0b2682f834a3facff88e310f52f07a5dcfd852d99637d386e79e", size = 415710 }, + { url = "https://files.pythonhosted.org/packages/71/af/c09da5ed58f9c002cf83adff7a4cdf3e6cee742aa9723395f8dcdb397233/orjson-3.10.12-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f31422ff9486ae484f10ffc51b5ab2a60359e92d0716fcce1b3593d7bb8a9af6", size = 142305 }, + { url = "https://files.pythonhosted.org/packages/17/d1/8612038d44f33fae231e9ba480d273bac2b0383ce9e77cb06bede1224ae3/orjson-3.10.12-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5f29c5d282bb2d577c2a6bbde88d8fdcc4919c593f806aac50133f01b733846e", size = 130815 }, + { url = "https://files.pythonhosted.org/packages/67/2c/d5f87834be3591555cfaf9aecdf28f480a6f0b4afeaac53bad534bf9518f/orjson-3.10.12-cp313-none-win32.whl", hash = "sha256:f45653775f38f63dc0e6cd4f14323984c3149c05d6007b58cb154dd080ddc0dc", size = 143664 }, + { url = "https://files.pythonhosted.org/packages/6a/05/7d768fa3ca23c9b3e1e09117abeded1501119f1d8de0ab722938c91ab25d/orjson-3.10.12-cp313-none-win_amd64.whl", hash = "sha256:229994d0c376d5bdc91d92b3c9e6be2f1fbabd4cc1b59daae1443a46ee5e9825", size = 134944 }, +] + +[[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.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/c8/e22c292035f1bac8b9f5237a2622305bc0304e776080b246f3df57c4ff9f/pre_commit-4.0.1.tar.gz", hash = "sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2", size = 191678 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/8f/496e10d51edd6671ebe0432e33ff800aa86775d2d147ce7d43389324a525/pre_commit-4.0.1-py2.py3-none-any.whl", hash = "sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878", size = 218713 }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.48" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/4f/feb5e137aff82f7c7f3248267b97451da3644f6cdc218edfe549fb354127/prompt_toolkit-3.0.48.tar.gz", hash = "sha256:d6623ab0477a80df74e646bdbc93621143f5caf104206aa29294d53de1a03d90", size = 424684 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/6a/fd08d94654f7e67c52ca30523a178b3f8ccc4237fce4be90d39c938a831a/prompt_toolkit-3.0.48-py3-none-any.whl", hash = "sha256:f49a827f90062e411f1ce1f854f2aedb3c23353244f8108b89283587397ac10e", size = 386595 }, +] + +[[package]] +name = "propcache" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/4d/5e5a60b78dbc1d464f8a7bbaeb30957257afdc8512cbb9dfd5659304f5cd/propcache-0.2.0.tar.gz", hash = "sha256:df81779732feb9d01e5d513fad0122efb3d53bbc75f61b2a4f29a020bc985e70", size = 40951 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/1c/71eec730e12aec6511e702ad0cd73c2872eccb7cad39de8ba3ba9de693ef/propcache-0.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:63f13bf09cc3336eb04a837490b8f332e0db41da66995c9fd1ba04552e516354", size = 80811 }, + { url = "https://files.pythonhosted.org/packages/89/c3/7e94009f9a4934c48a371632197406a8860b9f08e3f7f7d922ab69e57a41/propcache-0.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:608cce1da6f2672a56b24a015b42db4ac612ee709f3d29f27a00c943d9e851de", size = 46365 }, + { url = "https://files.pythonhosted.org/packages/c0/1d/c700d16d1d6903aeab28372fe9999762f074b80b96a0ccc953175b858743/propcache-0.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:466c219deee4536fbc83c08d09115249db301550625c7fef1c5563a584c9bc87", size = 45602 }, + { url = "https://files.pythonhosted.org/packages/2e/5e/4a3e96380805bf742712e39a4534689f4cddf5fa2d3a93f22e9fd8001b23/propcache-0.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc2db02409338bf36590aa985a461b2c96fce91f8e7e0f14c50c5fcc4f229016", size = 236161 }, + { url = "https://files.pythonhosted.org/packages/a5/85/90132481183d1436dff6e29f4fa81b891afb6cb89a7306f32ac500a25932/propcache-0.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a6ed8db0a556343d566a5c124ee483ae113acc9a557a807d439bcecc44e7dfbb", size = 244938 }, + { url = "https://files.pythonhosted.org/packages/4a/89/c893533cb45c79c970834274e2d0f6d64383ec740be631b6a0a1d2b4ddc0/propcache-0.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:91997d9cb4a325b60d4e3f20967f8eb08dfcb32b22554d5ef78e6fd1dda743a2", size = 243576 }, + { url = "https://files.pythonhosted.org/packages/8c/56/98c2054c8526331a05f205bf45cbb2cda4e58e56df70e76d6a509e5d6ec6/propcache-0.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c7dde9e533c0a49d802b4f3f218fa9ad0a1ce21f2c2eb80d5216565202acab4", size = 236011 }, + { url = "https://files.pythonhosted.org/packages/2d/0c/8b8b9f8a6e1abd869c0fa79b907228e7abb966919047d294ef5df0d136cf/propcache-0.2.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffcad6c564fe6b9b8916c1aefbb37a362deebf9394bd2974e9d84232e3e08504", size = 224834 }, + { url = "https://files.pythonhosted.org/packages/18/bb/397d05a7298b7711b90e13108db697732325cafdcd8484c894885c1bf109/propcache-0.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:97a58a28bcf63284e8b4d7b460cbee1edaab24634e82059c7b8c09e65284f178", size = 224946 }, + { url = "https://files.pythonhosted.org/packages/25/19/4fc08dac19297ac58135c03770b42377be211622fd0147f015f78d47cd31/propcache-0.2.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:945db8ee295d3af9dbdbb698cce9bbc5c59b5c3fe328bbc4387f59a8a35f998d", size = 217280 }, + { url = "https://files.pythonhosted.org/packages/7e/76/c79276a43df2096ce2aba07ce47576832b1174c0c480fe6b04bd70120e59/propcache-0.2.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:39e104da444a34830751715f45ef9fc537475ba21b7f1f5b0f4d71a3b60d7fe2", size = 220088 }, + { url = "https://files.pythonhosted.org/packages/c3/9a/8a8cf428a91b1336b883f09c8b884e1734c87f724d74b917129a24fe2093/propcache-0.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c5ecca8f9bab618340c8e848d340baf68bcd8ad90a8ecd7a4524a81c1764b3db", size = 233008 }, + { url = "https://files.pythonhosted.org/packages/25/7b/768a8969abd447d5f0f3333df85c6a5d94982a1bc9a89c53c154bf7a8b11/propcache-0.2.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c436130cc779806bdf5d5fae0d848713105472b8566b75ff70048c47d3961c5b", size = 237719 }, + { url = "https://files.pythonhosted.org/packages/ed/0d/e5d68ccc7976ef8b57d80613ac07bbaf0614d43f4750cf953f0168ef114f/propcache-0.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:191db28dc6dcd29d1a3e063c3be0b40688ed76434622c53a284e5427565bbd9b", size = 227729 }, + { url = "https://files.pythonhosted.org/packages/05/64/17eb2796e2d1c3d0c431dc5f40078d7282f4645af0bb4da9097fbb628c6c/propcache-0.2.0-cp311-cp311-win32.whl", hash = "sha256:5f2564ec89058ee7c7989a7b719115bdfe2a2fb8e7a4543b8d1c0cc4cf6478c1", size = 40473 }, + { url = "https://files.pythonhosted.org/packages/83/c5/e89fc428ccdc897ade08cd7605f174c69390147526627a7650fb883e0cd0/propcache-0.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e2e54267980349b723cff366d1e29b138b9a60fa376664a157a342689553f71", size = 44921 }, + { url = "https://files.pythonhosted.org/packages/7c/46/a41ca1097769fc548fc9216ec4c1471b772cc39720eb47ed7e38ef0006a9/propcache-0.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ee7606193fb267be4b2e3b32714f2d58cad27217638db98a60f9efb5efeccc2", size = 80800 }, + { url = "https://files.pythonhosted.org/packages/75/4f/93df46aab9cc473498ff56be39b5f6ee1e33529223d7a4d8c0a6101a9ba2/propcache-0.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:91ee8fc02ca52e24bcb77b234f22afc03288e1dafbb1f88fe24db308910c4ac7", size = 46443 }, + { url = "https://files.pythonhosted.org/packages/0b/17/308acc6aee65d0f9a8375e36c4807ac6605d1f38074b1581bd4042b9fb37/propcache-0.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2e900bad2a8456d00a113cad8c13343f3b1f327534e3589acc2219729237a2e8", size = 45676 }, + { url = "https://files.pythonhosted.org/packages/65/44/626599d2854d6c1d4530b9a05e7ff2ee22b790358334b475ed7c89f7d625/propcache-0.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f52a68c21363c45297aca15561812d542f8fc683c85201df0bebe209e349f793", size = 246191 }, + { url = "https://files.pythonhosted.org/packages/f2/df/5d996d7cb18df076debae7d76ac3da085c0575a9f2be6b1f707fe227b54c/propcache-0.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e41d67757ff4fbc8ef2af99b338bfb955010444b92929e9e55a6d4dcc3c4f09", size = 251791 }, + { url = "https://files.pythonhosted.org/packages/2e/6d/9f91e5dde8b1f662f6dd4dff36098ed22a1ef4e08e1316f05f4758f1576c/propcache-0.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a64e32f8bd94c105cc27f42d3b658902b5bcc947ece3c8fe7bc1b05982f60e89", size = 253434 }, + { url = "https://files.pythonhosted.org/packages/3c/e9/1b54b7e26f50b3e0497cd13d3483d781d284452c2c50dd2a615a92a087a3/propcache-0.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55346705687dbd7ef0d77883ab4f6fabc48232f587925bdaf95219bae072491e", size = 248150 }, + { url = "https://files.pythonhosted.org/packages/a7/ef/a35bf191c8038fe3ce9a414b907371c81d102384eda5dbafe6f4dce0cf9b/propcache-0.2.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00181262b17e517df2cd85656fcd6b4e70946fe62cd625b9d74ac9977b64d8d9", size = 233568 }, + { url = "https://files.pythonhosted.org/packages/97/d9/d00bb9277a9165a5e6d60f2142cd1a38a750045c9c12e47ae087f686d781/propcache-0.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6994984550eaf25dd7fc7bd1b700ff45c894149341725bb4edc67f0ffa94efa4", size = 229874 }, + { url = "https://files.pythonhosted.org/packages/8e/78/c123cf22469bdc4b18efb78893e69c70a8b16de88e6160b69ca6bdd88b5d/propcache-0.2.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:56295eb1e5f3aecd516d91b00cfd8bf3a13991de5a479df9e27dd569ea23959c", size = 225857 }, + { url = "https://files.pythonhosted.org/packages/31/1b/fd6b2f1f36d028820d35475be78859d8c89c8f091ad30e377ac49fd66359/propcache-0.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:439e76255daa0f8151d3cb325f6dd4a3e93043e6403e6491813bcaaaa8733887", size = 227604 }, + { url = "https://files.pythonhosted.org/packages/99/36/b07be976edf77a07233ba712e53262937625af02154353171716894a86a6/propcache-0.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f6475a1b2ecb310c98c28d271a30df74f9dd436ee46d09236a6b750a7599ce57", size = 238430 }, + { url = "https://files.pythonhosted.org/packages/0d/64/5822f496c9010e3966e934a011ac08cac8734561842bc7c1f65586e0683c/propcache-0.2.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3444cdba6628accf384e349014084b1cacd866fbb88433cd9d279d90a54e0b23", size = 244814 }, + { url = "https://files.pythonhosted.org/packages/fd/bd/8657918a35d50b18a9e4d78a5df7b6c82a637a311ab20851eef4326305c1/propcache-0.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a9d9b4d0a9b38d1c391bb4ad24aa65f306c6f01b512e10a8a34a2dc5675d348", size = 235922 }, + { url = "https://files.pythonhosted.org/packages/a8/6f/ec0095e1647b4727db945213a9f395b1103c442ef65e54c62e92a72a3f75/propcache-0.2.0-cp312-cp312-win32.whl", hash = "sha256:69d3a98eebae99a420d4b28756c8ce6ea5a29291baf2dc9ff9414b42676f61d5", size = 40177 }, + { url = "https://files.pythonhosted.org/packages/20/a2/bd0896fdc4f4c1db46d9bc361c8c79a9bf08ccc08ba054a98e38e7ba1557/propcache-0.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:ad9c9b99b05f163109466638bd30ada1722abb01bbb85c739c50b6dc11f92dc3", size = 44446 }, + { url = "https://files.pythonhosted.org/packages/a8/a7/5f37b69197d4f558bfef5b4bceaff7c43cc9b51adf5bd75e9081d7ea80e4/propcache-0.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ecddc221a077a8132cf7c747d5352a15ed763b674c0448d811f408bf803d9ad7", size = 78120 }, + { url = "https://files.pythonhosted.org/packages/c8/cd/48ab2b30a6b353ecb95a244915f85756d74f815862eb2ecc7a518d565b48/propcache-0.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0e53cb83fdd61cbd67202735e6a6687a7b491c8742dfc39c9e01e80354956763", size = 45127 }, + { url = "https://files.pythonhosted.org/packages/a5/ba/0a1ef94a3412aab057bd996ed5f0ac7458be5bf469e85c70fa9ceb43290b/propcache-0.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92fe151145a990c22cbccf9ae15cae8ae9eddabfc949a219c9f667877e40853d", size = 44419 }, + { url = "https://files.pythonhosted.org/packages/b4/6c/ca70bee4f22fa99eacd04f4d2f1699be9d13538ccf22b3169a61c60a27fa/propcache-0.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6a21ef516d36909931a2967621eecb256018aeb11fc48656e3257e73e2e247a", size = 229611 }, + { url = "https://files.pythonhosted.org/packages/19/70/47b872a263e8511ca33718d96a10c17d3c853aefadeb86dc26e8421184b9/propcache-0.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f88a4095e913f98988f5b338c1d4d5d07dbb0b6bad19892fd447484e483ba6b", size = 234005 }, + { url = "https://files.pythonhosted.org/packages/4f/be/3b0ab8c84a22e4a3224719099c1229ddfdd8a6a1558cf75cb55ee1e35c25/propcache-0.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a5b3bb545ead161be780ee85a2b54fdf7092815995661947812dde94a40f6fb", size = 237270 }, + { url = "https://files.pythonhosted.org/packages/04/d8/f071bb000d4b8f851d312c3c75701e586b3f643fe14a2e3409b1b9ab3936/propcache-0.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67aeb72e0f482709991aa91345a831d0b707d16b0257e8ef88a2ad246a7280bf", size = 231877 }, + { url = "https://files.pythonhosted.org/packages/93/e7/57a035a1359e542bbb0a7df95aad6b9871ebee6dce2840cb157a415bd1f3/propcache-0.2.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c997f8c44ec9b9b0bcbf2d422cc00a1d9b9c681f56efa6ca149a941e5560da2", size = 217848 }, + { url = "https://files.pythonhosted.org/packages/f0/93/d1dea40f112ec183398fb6c42fde340edd7bab202411c4aa1a8289f461b6/propcache-0.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2a66df3d4992bc1d725b9aa803e8c5a66c010c65c741ad901e260ece77f58d2f", size = 216987 }, + { url = "https://files.pythonhosted.org/packages/62/4c/877340871251145d3522c2b5d25c16a1690ad655fbab7bb9ece6b117e39f/propcache-0.2.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3ebbcf2a07621f29638799828b8d8668c421bfb94c6cb04269130d8de4fb7136", size = 212451 }, + { url = "https://files.pythonhosted.org/packages/7c/bb/a91b72efeeb42906ef58ccf0cdb87947b54d7475fee3c93425d732f16a61/propcache-0.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1235c01ddaa80da8235741e80815ce381c5267f96cc49b1477fdcf8c047ef325", size = 212879 }, + { url = "https://files.pythonhosted.org/packages/9b/7f/ee7fea8faac57b3ec5d91ff47470c6c5d40d7f15d0b1fccac806348fa59e/propcache-0.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3947483a381259c06921612550867b37d22e1df6d6d7e8361264b6d037595f44", size = 222288 }, + { url = "https://files.pythonhosted.org/packages/ff/d7/acd67901c43d2e6b20a7a973d9d5fd543c6e277af29b1eb0e1f7bd7ca7d2/propcache-0.2.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d5bed7f9805cc29c780f3aee05de3262ee7ce1f47083cfe9f77471e9d6777e83", size = 228257 }, + { url = "https://files.pythonhosted.org/packages/8d/6f/6272ecc7a8daad1d0754cfc6c8846076a8cb13f810005c79b15ce0ef0cf2/propcache-0.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e4a91d44379f45f5e540971d41e4626dacd7f01004826a18cb048e7da7e96544", size = 221075 }, + { url = "https://files.pythonhosted.org/packages/7c/bd/c7a6a719a6b3dd8b3aeadb3675b5783983529e4a3185946aa444d3e078f6/propcache-0.2.0-cp313-cp313-win32.whl", hash = "sha256:f902804113e032e2cdf8c71015651c97af6418363bea8d78dc0911d56c335032", size = 39654 }, + { url = "https://files.pythonhosted.org/packages/88/e7/0eef39eff84fa3e001b44de0bd41c7c0e3432e7648ffd3d64955910f002d/propcache-0.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:8f188cfcc64fb1266f4684206c9de0e80f54622c3f22a910cbd200478aeae61e", size = 43705 }, + { url = "https://files.pythonhosted.org/packages/3d/b6/e6d98278f2d49b22b4d033c9f792eda783b9ab2094b041f013fc69bcde87/propcache-0.2.0-py3-none-any.whl", hash = "sha256:2ccc28197af5313706511fab3a8b66dcd6da067a1331372c82ea1cb74285e036", size = 11603 }, +] + +[[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.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, +] + +[[package]] +name = "pytest" +version = "8.3.3" +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/8b/6c/62bbd536103af674e227c41a8f3dcd022d591f6eed5facb5a0f31ee33bbc/pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", size = 1442487 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2", size = 342341 }, +] + +[[package]] +name = "pytest-asyncio" +version = "0.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/c6cf50ce320cf8611df7a1254d86233b3df7cc07f9b5f5cbcb82e08aa534/pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276", size = 49855 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/31/6607dab48616902f76885dfcf62c08d929796fc3b2d2318faf9fd54dbed9/pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b", size = 18024 }, +] + +[[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.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "freezegun" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/fa/a93d40dd50f712c276a5a15f9c075bee932cc4d28c376e60b4a35904976d/pytest_freezer-0.4.8.tar.gz", hash = "sha256:8ee2f724b3ff3540523fa355958a22e6f4c1c819928b78a7a183ae4248ce6ee6", size = 3212 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/4e/ba488639516a341810aeaeb4b32b70abb0923e53f7c4d14d673dc114d35a/pytest_freezer-0.4.8-py3-none-any.whl", hash = "sha256:644ce7ddb8ba52b92a1df0a80a699bad2b93514c55cf92e9f2517b68ebe74814", size = 3228 }, +] + +[[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.8.1" +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.7.4" }, + { 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.7.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/8b/bc4e0dfa1245b07cf14300e10319b98e958a53ff074c1dd86b35253a8c2a/ruff-0.7.4.tar.gz", hash = "sha256:cd12e35031f5af6b9b93715d8c4f40360070b2041f81273d0527683d5708fce2", size = 3275547 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/4b/f5094719e254829766b807dadb766841124daba75a37da83e292ae5ad12f/ruff-0.7.4-py3-none-linux_armv6l.whl", hash = "sha256:a4919925e7684a3f18e18243cd6bea7cfb8e968a6eaa8437971f681b7ec51478", size = 10447512 }, + { url = "https://files.pythonhosted.org/packages/9e/1d/3d2d2c9f601cf6044799c5349ff5267467224cefed9b35edf5f1f36486e9/ruff-0.7.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cfb365c135b830778dda8c04fb7d4280ed0b984e1aec27f574445231e20d6c63", size = 10235436 }, + { url = "https://files.pythonhosted.org/packages/62/83/42a6ec6216ded30b354b13e0e9327ef75a3c147751aaf10443756cb690e9/ruff-0.7.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:63a569b36bc66fbadec5beaa539dd81e0527cb258b94e29e0531ce41bacc1f20", size = 9888936 }, + { url = "https://files.pythonhosted.org/packages/4d/26/e1e54893b13046a6ad05ee9b89ee6f71542ba250f72b4c7a7d17c3dbf73d/ruff-0.7.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d06218747d361d06fd2fdac734e7fa92df36df93035db3dc2ad7aa9852cb109", size = 10697353 }, + { url = "https://files.pythonhosted.org/packages/21/24/98d2e109c4efc02bfef144ec6ea2c3e1217e7ce0cfddda8361d268dfd499/ruff-0.7.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e0cea28d0944f74ebc33e9f934238f15c758841f9f5edd180b5315c203293452", size = 10228078 }, + { url = "https://files.pythonhosted.org/packages/ad/b7/964c75be9bc2945fc3172241b371197bb6d948cc69e28bc4518448c368f3/ruff-0.7.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80094ecd4793c68b2571b128f91754d60f692d64bc0d7272ec9197fdd09bf9ea", size = 11264823 }, + { url = "https://files.pythonhosted.org/packages/12/8d/20abdbf705969914ce40988fe71a554a918deaab62c38ec07483e77866f6/ruff-0.7.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:997512325c6620d1c4c2b15db49ef59543ef9cd0f4aa8065ec2ae5103cedc7e7", size = 11951855 }, + { url = "https://files.pythonhosted.org/packages/b8/fc/6519ce58c57b4edafcdf40920b7273dfbba64fc6ebcaae7b88e4dc1bf0a8/ruff-0.7.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00b4cf3a6b5fad6d1a66e7574d78956bbd09abfd6c8a997798f01f5da3d46a05", size = 11516580 }, + { url = "https://files.pythonhosted.org/packages/37/1a/5ec1844e993e376a86eb2456496831ed91b4398c434d8244f89094758940/ruff-0.7.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7dbdc7d8274e1422722933d1edddfdc65b4336abf0b16dfcb9dedd6e6a517d06", size = 12692057 }, + { url = "https://files.pythonhosted.org/packages/50/90/76867152b0d3c05df29a74bb028413e90f704f0f6701c4801174ba47f959/ruff-0.7.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e92dfb5f00eaedb1501b2f906ccabfd67b2355bdf117fea9719fc99ac2145bc", size = 11085137 }, + { url = "https://files.pythonhosted.org/packages/c8/eb/0a7cb6059ac3555243bd026bb21785bbc812f7bbfa95a36c101bd72b47ae/ruff-0.7.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3bd726099f277d735dc38900b6a8d6cf070f80828877941983a57bca1cd92172", size = 10681243 }, + { url = "https://files.pythonhosted.org/packages/5e/76/2270719dbee0fd35780b05c08a07b7a726c3da9f67d9ae89ef21fc18e2e5/ruff-0.7.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2e32829c429dd081ee5ba39aef436603e5b22335c3d3fff013cd585806a6486a", size = 10319187 }, + { url = "https://files.pythonhosted.org/packages/9f/e5/39100f72f8ba70bec1bd329efc880dea8b6c1765ea1cb9d0c1c5f18b8d7f/ruff-0.7.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:662a63b4971807623f6f90c1fb664613f67cc182dc4d991471c23c541fee62dd", size = 10803715 }, + { url = "https://files.pythonhosted.org/packages/a5/89/40e904784f305fb56850063f70a998a64ebba68796d823dde67e89a24691/ruff-0.7.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:876f5e09eaae3eb76814c1d3b68879891d6fde4824c015d48e7a7da4cf066a3a", size = 11162912 }, + { url = "https://files.pythonhosted.org/packages/8d/1b/dd77503b3875c51e3dbc053fd8367b845ab8b01c9ca6d0c237082732856c/ruff-0.7.4-py3-none-win32.whl", hash = "sha256:75c53f54904be42dd52a548728a5b572344b50d9b2873d13a3f8c5e3b91f5cac", size = 8702767 }, + { url = "https://files.pythonhosted.org/packages/63/76/253ddc3e89e70165bba952ecca424b980b8d3c2598ceb4fc47904f424953/ruff-0.7.4-py3-none-win_amd64.whl", hash = "sha256:745775c7b39f914238ed1f1b0bebed0b9155a17cd8bc0b08d3c87e4703b990d6", size = 9497534 }, + { url = "https://files.pythonhosted.org/packages/aa/70/f8724f31abc0b329ca98b33d73c14020168babcf71b0cba3cded5d9d0e66/ruff-0.7.4-py3-none-win_arm64.whl", hash = "sha256:11bff065102c3ae9d3ea4dc9ecdfe5a5171349cdd0787c1fc64761212fc9cf1f", size = 8851590 }, +] + +[[package]] +name = "six" +version = "1.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/39/171f1c67cd00715f190ba0b100d606d440a28c93c7714febeca8b79af85e/six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", size = 34041 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053 }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[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.17" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/fe/8a6d8763674b3d3814a6008a83eb8002b6da188710dd7f4654ec77b4a8ac/sphinxcontrib-programoutput-0.17.tar.gz", hash = "sha256:300ee9b8caee8355d25cc74b4d1c7efd12e608d2ad165e3141d31e6fbc152b7f", size = 24067 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/ee/b7be4b3f45f4e36bfa6c444cd234098e0d09880379c67a43e6bb9ab99a86/sphinxcontrib_programoutput-0.17-py2.py3-none-any.whl", hash = "sha256:0ef1c1d9159dbe7103077748214305eb4e0138e861feb71c0c346afc5fe97f84", size = 22131 }, +] + +[[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.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/e4/1b6cbcc82d8832dd0ce34767d5c560df8a3547ad8cbc427f34601415930a/tomli-2.1.0.tar.gz", hash = "sha256:3f646cae2aec94e17d04973e4249548320197cfabdf130015d023de4b74d8ab8", size = 16622 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/f7/4da0ffe1892122c9ea096c57f64c2753ae5dd3ce85488802d11b0992cc6d/tomli-2.1.0-py3-none-any.whl", hash = "sha256:a5c57c3d1c56f5ccdf89f6523458f60ef716e210fc47c4cfb188c5ba473e0391", size = 13750 }, +] + +[[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 = "2024.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/34/943888654477a574a86a98e9896bae89c7aa15078ec29f490fef2f1e5384/tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc", size = 193282 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/ab/7e5f53c3b9d14972843a647d8d7a853969a58aecc7559cb3267302c94774/tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd", size = 346586 }, +] + +[[package]] +name = "urllib3" +version = "2.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338 }, +] + +[[package]] +name = "virtualenv" +version = "20.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bf/75/53316a5a8050069228a2f6d11f32046cfa94fbb6cc3f08703f59b873de2e/virtualenv-20.28.0.tar.gz", hash = "sha256:2c9c3262bb8e7b87ea801d715fae4495e6032450c71d2309be9550e7364049aa", size = 7650368 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/f9/0919cf6f1432a8c4baa62511f8f8da8225432d22e83e3476f5be1a1edc6e/virtualenv-20.28.0-py3-none-any.whl", hash = "sha256:23eae1b4516ecd610481eda647f3a7c09aea295055337331bb4e6892ecce47b0", size = 4276702 }, +] + +[[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.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/4b/53db4ecad4d54535aff3dfda1f00d6363d79455f62b11b8ca97b82746bd2/yarl-1.18.0.tar.gz", hash = "sha256:20d95535e7d833889982bfe7cc321b7f63bf8879788fee982c76ae2b24cfb715", size = 180098 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/45/6ad7135d1c4ad3a6a49e2c37dc78a1805a7871879c03c3495d64c9605d49/yarl-1.18.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b8e8c516dc4e1a51d86ac975b0350735007e554c962281c432eaa5822aa9765c", size = 141283 }, + { url = "https://files.pythonhosted.org/packages/45/6d/24b70ae33107d6eba303ed0ebfdf1164fe2219656e7594ca58628ebc0f1d/yarl-1.18.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e6b4466714a73f5251d84b471475850954f1fa6acce4d3f404da1d55d644c34", size = 94082 }, + { url = "https://files.pythonhosted.org/packages/8a/0e/da720989be11b662ca847ace58f468b52310a9b03e52ac62c144755f9d75/yarl-1.18.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c893f8c1a6d48b25961e00922724732d00b39de8bb0b451307482dc87bddcd74", size = 92017 }, + { url = "https://files.pythonhosted.org/packages/f5/76/e5c91681fa54658943cb88673fb19b3355c3a8ae911a33a2621b6320990d/yarl-1.18.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13aaf2bdbc8c86ddce48626b15f4987f22e80d898818d735b20bd58f17292ee8", size = 340359 }, + { url = "https://files.pythonhosted.org/packages/cf/77/02cf72f09dea20980dea4ebe40dfb2c24916b864aec869a19f715428e0f0/yarl-1.18.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd21c0128e301851de51bc607b0a6da50e82dc34e9601f4b508d08cc89ee7929", size = 356336 }, + { url = "https://files.pythonhosted.org/packages/17/66/83a88d04e4fc243dd26109f3e3d6412f67819ab1142dadbce49706ef4df4/yarl-1.18.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:205de377bd23365cd85562c9c6c33844050a93661640fda38e0567d2826b50df", size = 353730 }, + { url = "https://files.pythonhosted.org/packages/76/77/0b205a532d22756ab250ab21924d362f910a23d641c82faec1c4ad7f6077/yarl-1.18.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed69af4fe2a0949b1ea1d012bf065c77b4c7822bad4737f17807af2adb15a73c", size = 343882 }, + { url = "https://files.pythonhosted.org/packages/0b/47/2081ddce3da6096889c3947bdc21907d0fa15939909b10219254fe116841/yarl-1.18.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8e1c18890091aa3cc8a77967943476b729dc2016f4cfe11e45d89b12519d4a93", size = 335873 }, + { url = "https://files.pythonhosted.org/packages/25/3c/437304394494e757ae927c9a81bacc4bcdf7351a1d4e811d95b02cb6dbae/yarl-1.18.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:91b8fb9427e33f83ca2ba9501221ffaac1ecf0407f758c4d2f283c523da185ee", size = 347725 }, + { url = "https://files.pythonhosted.org/packages/c6/fb/fa6c642bc052fbe6370ed5da765579650510157dea354fe9e8177c3bc34a/yarl-1.18.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:536a7a8a53b75b2e98ff96edb2dfb91a26b81c4fed82782035767db5a465be46", size = 346161 }, + { url = "https://files.pythonhosted.org/packages/b0/09/8c0cf68a0fcfe3b060c9e5857bb35735bc72a4cf4075043632c636d007e9/yarl-1.18.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a64619a9c47c25582190af38e9eb382279ad42e1f06034f14d794670796016c0", size = 349924 }, + { url = "https://files.pythonhosted.org/packages/bf/4b/1efe10fd51e2cedf53195d688fa270efbcd64a015c61d029d49c20bf0af7/yarl-1.18.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c73a6bbc97ba1b5a0c3c992ae93d721c395bdbb120492759b94cc1ac71bc6350", size = 361865 }, + { url = "https://files.pythonhosted.org/packages/0b/1b/2b5efd6df06bf938f7e154dee8e2ab22d148f3311a92bf4da642aaaf2fc5/yarl-1.18.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a173401d7821a2a81c7b47d4e7d5c4021375a1441af0c58611c1957445055056", size = 366030 }, + { url = "https://files.pythonhosted.org/packages/f8/db/786a5684f79278e62271038a698f56a51960f9e643be5d3eff82712f0b1c/yarl-1.18.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7520e799b1f84e095cce919bd6c23c9d49472deeef25fe1ef960b04cca51c3fc", size = 358902 }, + { url = "https://files.pythonhosted.org/packages/91/2f/437d0de062f1a3e3cb17573971b3832232443241133580c2ba3da5001d06/yarl-1.18.0-cp311-cp311-win32.whl", hash = "sha256:c4cb992d8090d5ae5f7afa6754d7211c578be0c45f54d3d94f7781c495d56716", size = 84138 }, + { url = "https://files.pythonhosted.org/packages/9d/85/035719a9266bce85ecde820aa3f8c46f3b18c3d7ba9ff51367b2fa4ae2a2/yarl-1.18.0-cp311-cp311-win_amd64.whl", hash = "sha256:52c136f348605974c9b1c878addd6b7a60e3bf2245833e370862009b86fa4689", size = 90765 }, + { url = "https://files.pythonhosted.org/packages/23/36/c579b80a5c76c0d41c8e08baddb3e6940dfc20569db579a5691392c52afa/yarl-1.18.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1ece25e2251c28bab737bdf0519c88189b3dd9492dc086a1d77336d940c28ced", size = 142376 }, + { url = "https://files.pythonhosted.org/packages/0c/5f/e247dc7c0607a0c505fea6c839721844bee55686dfb183c7d7b8ef8a9cb1/yarl-1.18.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:454902dc1830d935c90b5b53c863ba2a98dcde0fbaa31ca2ed1ad33b2a7171c6", size = 94692 }, + { url = "https://files.pythonhosted.org/packages/eb/e1/3081b578a6f21961711b9a1c49c2947abb3b0d0dd9537378fb06777ce8ee/yarl-1.18.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01be8688fc211dc237e628fcc209dda412d35de7642453059a0553747018d075", size = 92527 }, + { url = "https://files.pythonhosted.org/packages/2f/fa/d9e1b9fbafa4cc82cd3980b5314741b33c2fe16308d725449a23aed32021/yarl-1.18.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d26f1fa9fa2167bb238f6f4b20218eb4e88dd3ef21bb8f97439fa6b5313e30d", size = 332096 }, + { url = "https://files.pythonhosted.org/packages/93/b6/dd27165114317875838e216214fb86338dc63d2e50855a8f2a12de2a7fe5/yarl-1.18.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b234a4a9248a9f000b7a5dfe84b8cb6210ee5120ae70eb72a4dcbdb4c528f72f", size = 342047 }, + { url = "https://files.pythonhosted.org/packages/fc/9f/bad434b5279ae7a356844e14dc771c3d29eb928140bbc01621af811c8a27/yarl-1.18.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fe94d1de77c4cd8caff1bd5480e22342dbd54c93929f5943495d9c1e8abe9f42", size = 341712 }, + { url = "https://files.pythonhosted.org/packages/9a/9f/63864f43d131ba8c8cdf1bde5dd3f02f0eff8a7c883a5d7fad32f204fda5/yarl-1.18.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b4c90c5363c6b0a54188122b61edb919c2cd1119684999d08cd5e538813a28e", size = 336654 }, + { url = "https://files.pythonhosted.org/packages/20/30/b4542bbd9be73de155213207eec019f6fe6495885f7dd59aa1ff705a041b/yarl-1.18.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49a98ecadc5a241c9ba06de08127ee4796e1009555efd791bac514207862b43d", size = 325484 }, + { url = "https://files.pythonhosted.org/packages/69/bc/e2a9808ec26989cf0d1b98fe7b3cc45c1c6506b5ea4fe43ece5991f28f34/yarl-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9106025c7f261f9f5144f9aa7681d43867eed06349a7cfb297a1bc804de2f0d1", size = 344213 }, + { url = "https://files.pythonhosted.org/packages/e2/17/0ee5a68886aca1a8071b0d24a1e1c0fd9970dead2ef2d5e26e027fb7ce88/yarl-1.18.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f275ede6199d0f1ed4ea5d55a7b7573ccd40d97aee7808559e1298fe6efc8dbd", size = 340517 }, + { url = "https://files.pythonhosted.org/packages/fd/db/1fe4ef38ee852bff5ec8f5367d718b3a7dac7520f344b8e50306f68a2940/yarl-1.18.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f7edeb1dcc7f50a2c8e08b9dc13a413903b7817e72273f00878cb70e766bdb3b", size = 346234 }, + { url = "https://files.pythonhosted.org/packages/b4/ee/5e5bccdb821eb9949ba66abb4d19e3299eee00282e37b42f65236120e892/yarl-1.18.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c083f6dd6951b86e484ebfc9c3524b49bcaa9c420cb4b2a78ef9f7a512bfcc85", size = 359625 }, + { url = "https://files.pythonhosted.org/packages/3f/43/95a64d9e7ab4aa1c34fc5ea0edb35b581bc6ad33fd960a8ae34c2040b319/yarl-1.18.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:80741ec5b471fbdfb997821b2842c59660a1c930ceb42f8a84ba8ca0f25a66aa", size = 364239 }, + { url = "https://files.pythonhosted.org/packages/40/19/09ce976c624c9d3cc898f0be5035ddef0c0759d85b2313321cfe77b69915/yarl-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b1a3297b9cad594e1ff0c040d2881d7d3a74124a3c73e00c3c71526a1234a9f7", size = 357599 }, + { url = "https://files.pythonhosted.org/packages/7d/35/6f33fd29791af2ec161aebe8abe63e788c2b74a6c7e8f29c92e5f5e96849/yarl-1.18.0-cp312-cp312-win32.whl", hash = "sha256:cd6ab7d6776c186f544f893b45ee0c883542b35e8a493db74665d2e594d3ca75", size = 83832 }, + { url = "https://files.pythonhosted.org/packages/4e/8e/cdb40ef98597be107de67b11e2f1f23f911e0f1416b938885d17a338e304/yarl-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:039c299a0864d1f43c3e31570045635034ea7021db41bf4842693a72aca8df3a", size = 90132 }, + { url = "https://files.pythonhosted.org/packages/2b/77/2196b657c66f97adaef0244e9e015f30eac0df59c31ad540f79ce328feed/yarl-1.18.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6fb64dd45453225f57d82c4764818d7a205ee31ce193e9f0086e493916bd4f72", size = 140512 }, + { url = "https://files.pythonhosted.org/packages/0e/d8/2bb6e26fddba5c01bad284e4571178c651b97e8e06318efcaa16e07eb9fd/yarl-1.18.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3adaaf9c6b1b4fc258584f4443f24d775a2086aee82d1387e48a8b4f3d6aecf6", size = 93875 }, + { url = "https://files.pythonhosted.org/packages/54/e4/99fbb884dd9f814fb0037dc1783766bb9edcd57b32a76f3ec5ac5c5772d7/yarl-1.18.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:da206d1ec78438a563c5429ab808a2b23ad7bc025c8adbf08540dde202be37d5", size = 91705 }, + { url = "https://files.pythonhosted.org/packages/3b/a2/5bd86eca9449e6b15d3b08005cf4e58e3da972240c2bee427b358c311549/yarl-1.18.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:576d258b21c1db4c6449b1c572c75d03f16a482eb380be8003682bdbe7db2f28", size = 333325 }, + { url = "https://files.pythonhosted.org/packages/94/50/a218da5f159cd985685bc72c500bb1a7fd2d60035d2339b8a9d9e1f99194/yarl-1.18.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c60e547c0a375c4bfcdd60eef82e7e0e8698bf84c239d715f5c1278a73050393", size = 344121 }, + { url = "https://files.pythonhosted.org/packages/a4/e3/830ae465811198b4b5ebecd674b5b3dca4d222af2155eb2144bfe190bbb8/yarl-1.18.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3818eabaefb90adeb5e0f62f047310079d426387991106d4fbf3519eec7d90a", size = 345163 }, + { url = "https://files.pythonhosted.org/packages/7a/74/05c4326877ca541eee77b1ef74b7ac8081343d3957af8f9291ca6eca6fec/yarl-1.18.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5f72421246c21af6a92fbc8c13b6d4c5427dfd949049b937c3b731f2f9076bd", size = 339130 }, + { url = "https://files.pythonhosted.org/packages/29/42/842f35aa1dae25d132119ee92185e8c75d8b9b7c83346506bd31e9fa217f/yarl-1.18.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7fa7d37f2ada0f42e0723632993ed422f2a679af0e200874d9d861720a54f53e", size = 326418 }, + { url = "https://files.pythonhosted.org/packages/f9/ed/65c0514f2d1e8b92a61f564c914381d078766cab38b5fbde355b3b3af1fb/yarl-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:42ba84e2ac26a3f252715f8ec17e6fdc0cbf95b9617c5367579fafcd7fba50eb", size = 345204 }, + { url = "https://files.pythonhosted.org/packages/23/31/351f64f0530c372fa01160f38330f44478e7bf3092f5ce2bfcb91605561d/yarl-1.18.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6a49ad0102c0f0ba839628d0bf45973c86ce7b590cdedf7540d5b1833ddc6f00", size = 341652 }, + { url = "https://files.pythonhosted.org/packages/49/aa/0c6e666c218d567727c1d040d01575685e7f9b18052fd68a59c9f61fe5d9/yarl-1.18.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:96404e8d5e1bbe36bdaa84ef89dc36f0e75939e060ca5cd45451aba01db02902", size = 347257 }, + { url = "https://files.pythonhosted.org/packages/36/0b/33a093b0e13bb8cd0f27301779661ff325270b6644929001f8f33307357d/yarl-1.18.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a0509475d714df8f6d498935b3f307cd122c4ca76f7d426c7e1bb791bcd87eda", size = 359735 }, + { url = "https://files.pythonhosted.org/packages/a8/92/dcc0b37c48632e71ffc2b5f8b0509347a0bde55ab5862ff755dce9dd56c4/yarl-1.18.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1ff116f0285b5c8b3b9a2680aeca29a858b3b9e0402fc79fd850b32c2bcb9f8b", size = 365982 }, + { url = "https://files.pythonhosted.org/packages/0e/39/30e2a24a7a6c628dccb13eb6c4a03db5f6cd1eb2c6cda56a61ddef764c11/yarl-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2580c1d7e66e6d29d6e11855e3b1c6381971e0edd9a5066e6c14d79bc8967af", size = 360128 }, + { url = "https://files.pythonhosted.org/packages/76/13/12b65dca23b1fb8ae44269a4d24048fd32ac90b445c985b0a46fdfa30cfe/yarl-1.18.0-cp313-cp313-win32.whl", hash = "sha256:14408cc4d34e202caba7b5ac9cc84700e3421a9e2d1b157d744d101b061a4a88", size = 309888 }, + { url = "https://files.pythonhosted.org/packages/f6/60/478d3d41a4bf0b9e7dca74d870d114e775d1ff7156b7d1e0e9972e8f97fd/yarl-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1db1537e9cb846eb0ff206eac667f627794be8b71368c1ab3207ec7b6f8c5afc", size = 315459 }, + { url = "https://files.pythonhosted.org/packages/30/9c/3f7ab894a37b1520291247cbc9ea6756228d098dae5b37eec848d404a204/yarl-1.18.0-py3-none-any.whl", hash = "sha256:dbf53db46f7cf176ee01d8d98c39381440776fcda13779d269a8ba664f69bec0", size = 44840 }, +]